diff options
402 files changed, 11226 insertions, 4040 deletions
diff --git a/.gitignore b/.gitignore index 680651986e8..51b4d06b01b 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ eslint-report.html /config/unicorn.rb /config/secrets.yml /config/sidekiq.yml +/config/registry.key /coverage/* /coverage-javascript/ /db/*.sqlite3 diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 8f0916f768f..a918a2aa18d 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -0.5.0 +0.6.0 @@ -144,6 +144,9 @@ gem 'sidekiq-cron', '~> 0.4.4' gem 'redis-namespace', '~> 1.5.2' gem 'sidekiq-limit_fetch', '~> 3.4' +# Cron Parser +gem 'rufus-scheduler', '~> 3.1.10' + # HTTP requests gem 'httparty', '~> 0.13.3' diff --git a/Gemfile.lock b/Gemfile.lock index 50ca9af7a7a..e4603df5f7f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -987,6 +987,7 @@ DEPENDENCIES rubocop-rspec (~> 1.12.0) ruby-fogbugz (~> 0.2.1) ruby-prof (~> 0.16.2) + rufus-scheduler (~> 3.1.10) rugged (~> 0.25.1.1) sanitize (~> 2.0) sass-rails (~> 5.0.6) diff --git a/PROCESS.md b/PROCESS.md index fead93bd4cf..2f331ee9169 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -33,7 +33,7 @@ core team members will mention this person. ### Merge request coaching Several people from the [GitLab team][team] are helping community members to get -their contributions accepted by meeting our [Definition of done](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#definition-of-done). +their contributions accepted by meeting our [Definition of done][done]. What you can expect from them is described at https://about.gitlab.com/jobs/merge-request-coach/. @@ -64,6 +64,49 @@ Merge requests may still be merged into master during this period, but they will go into the _next_ release, unless they are manually cherry-picked into the stable branch. By freezing the stable branches 2 weeks prior to a release, we reduce the risk of a last minute merge request potentially breaking things. +### Between the 1st and the 7th + +These types of merge requests need special consideration: + +* **Large features**: a large feature is one that is highlighted in the kick-off + and the release blogpost; typically this will have its own channel in Slack + and a dedicated team with front-end, back-end, and UX. +* **Small features**: any other feature request. + +**Large features** must be with a maintainer **by the 1st**. It's OK if they +aren't completely done, but this allows the maintainer enough time to make the +decision about whether this can make it in before the freeze. If the maintainer +doesn't think it will make it, they should inform the developers working on it +and the Product Manager responsible for the feature. + +**Small features** must be with a reviewer (not necessarily maintainer) **by the +3rd**. + +Most merge requests from the community do not have a specific release +target. However, if one does and falls into either of the above categories, it's +the reviewer's responsibility to manage the above communication and assignment +on behalf of the community member. + +### On the 7th + +Merge requests should still be complete, following the +[definition of done][done]. The single exception is documentation, and this can +only be left until after the freeze if: + +* There is a follow-up issue to add documentation. +* It is assigned to the person writing documentation for this feature, and they + are aware of it. +* It is in the correct milestone, with the ~Deliverable label. + +All Community Edition merge requests from GitLab team members merged on the +freeze date (the 7th) should have a corresponding Enterprise Edition merge +request, even if there are no conflicts. This is to reduce the size of the +subsequent EE merge, as we often merge a lot to CE on the release date. For more +information, see +[limit conflicts with EE when developing on CE][limit_ee_conflicts]. + +### Between the 7th and the 22nd + Once the stable branch is frozen, only fixes for regressions (bugs introduced in that same release) and security issues will be cherry-picked into the stable branch. Any merge requests cherry-picked into the stable branch for a previous release will also be picked into the latest stable branch. @@ -158,3 +201,5 @@ still an issue I encourage you to open it on the [GitLab.com issue tracker](http [contribution acceptance criteria]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#contribution-acceptance-criteria ["Implement design & UI elements" guidelines]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#implement-design-ui-elements [Thoughtbot code review guide]: https://github.com/thoughtbot/guides/tree/master/code-review +[done]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#definition-of-done +[limit_ee_conflicts]: https://docs.gitlab.com/ce/development/limit_ee_conflicts.html diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 4f63c7988f5..67106e85a37 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -263,7 +263,8 @@ AwardsHandler.prototype.addAward = function addAward( this.addAwardToEmojiBar(votesBlock, normalizedEmoji, checkMutuality); return typeof callback === 'function' ? callback() : undefined; }); - return $('.emoji-menu').removeClass('is-visible'); + $('.emoji-menu').removeClass('is-visible'); + $('.js-add-award.is-active').removeClass('is-active'); }; AwardsHandler.prototype.addAwardToEmojiBar = function addAwardToEmojiBar( diff --git a/app/assets/javascripts/blob/blob_fork_suggestion.js b/app/assets/javascripts/blob/blob_fork_suggestion.js new file mode 100644 index 00000000000..aa9a4e1c99a --- /dev/null +++ b/app/assets/javascripts/blob/blob_fork_suggestion.js @@ -0,0 +1,15 @@ +function BlobForkSuggestion(openButton, cancelButton, suggestionSection) { + if (openButton) { + openButton.addEventListener('click', () => { + suggestionSection.classList.remove('hidden'); + }); + } + + if (cancelButton) { + cancelButton.addEventListener('click', () => { + suggestionSection.classList.add('hidden'); + }); + } +} + +export default BlobForkSuggestion; diff --git a/app/assets/javascripts/build.js b/app/assets/javascripts/build.js index fe54ecffdfe..7cb022c848a 100644 --- a/app/assets/javascripts/build.js +++ b/app/assets/javascripts/build.js @@ -84,10 +84,10 @@ window.Build = (function() { var removeRefreshStatuses = ['success', 'failed', 'canceled', 'skipped']; return $.ajax({ - url: this.buildUrl, + url: this.pageUrl + "/trace.json", dataType: 'json', success: function(buildData) { - $('.js-build-output').html(buildData.trace_html); + $('.js-build-output').html(buildData.html); gl.utils.setCiStatusFavicon(`${this.pageUrl}/status.json`); if (window.location.hash === DOWN_BUILD_TRACE) { $("html,body").scrollTop(this.$buildTrace.height()); diff --git a/app/assets/javascripts/comment_type_toggle.js b/app/assets/javascripts/comment_type_toggle.js new file mode 100644 index 00000000000..34878f90050 --- /dev/null +++ b/app/assets/javascripts/comment_type_toggle.js @@ -0,0 +1,41 @@ +import DropLab from '~/droplab/drop_lab'; +import InputSetter from '~/droplab/plugins/input_setter'; + +class CommentTypeToggle { + constructor(dropdownTrigger, dropdownList, noteTypeInput, submitButton, closeButton) { + this.dropdownTrigger = dropdownTrigger; + this.dropdownList = dropdownList; + this.noteTypeInput = noteTypeInput; + this.submitButton = submitButton; + this.closeButton = closeButton; + } + + initDroplab() { + this.droplab = new DropLab(); + + const inputSetterConfig = [{ + input: this.noteTypeInput, + valueAttribute: 'data-value', + }, + { + input: this.submitButton, + valueAttribute: 'data-button-text', + }]; + if (this.closeButton) { + inputSetterConfig.push({ + input: this.closeButton, + valueAttribute: 'data-secondary-button-text', + }, { + input: this.closeButton, + valueAttribute: 'data-secondary-button-text', + inputAttribute: 'data-alternative-text', + }); + } + + this.droplab.init(this.dropdownTrigger, this.dropdownList, [InputSetter], { + InputSetter: inputSetterConfig, + }); + } +} + +export default CommentTypeToggle; diff --git a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js index fc2f20e3bcb..eb76b7d15fd 100644 --- a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js +++ b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js @@ -42,10 +42,14 @@ import Vue from 'vue'; } }, created() { - this.discussion = CommentsStore.state[this.discussionId]; + if (this.discussionId) { + this.discussion = CommentsStore.state[this.discussionId]; + } }, mounted: function () { - const $textarea = $(`#new-discussion-note-form-${this.discussionId} .note-textarea`); + if (!this.discussionId) return; + + const $textarea = $(`.js-discussion-note-form[data-discussion-id=${this.discussionId}] .note-textarea`); this.textareaIsEmpty = $textarea.val() === ''; $textarea.on('input.comment-and-resolve-btn', () => { @@ -53,7 +57,9 @@ import Vue from 'vue'; }); }, destroyed: function () { - $(`#new-discussion-note-form-${this.discussionId} .note-textarea`).off('input.comment-and-resolve-btn'); + if (!this.discussionId) return; + + $(`.js-discussion-note-form[data-discussion-id=${this.discussionId}] .note-textarea`).off('input.comment-and-resolve-btn'); } }); diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 9c7acc903d1..307c6a306d1 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -43,6 +43,7 @@ import GroupsList from './groups_list'; import ProjectsList from './projects_list'; import MiniPipelineGraph from './mini_pipeline_graph_dropdown'; import BlobLinePermalinkUpdater from './blob/blob_line_permalink_updater'; +import BlobForkSuggestion from './blob/blob_fork_suggestion'; import UserCallout from './user_callout'; const ShortcutsBlob = require('./shortcuts_blob'); @@ -86,6 +87,12 @@ const ShortcutsBlob = require('./shortcuts_blob'); skipResetBindings: true, fileBlobPermalinkUrl, }); + + new BlobForkSuggestion( + document.querySelector('.js-edit-blob-link-fork-toggler'), + document.querySelector('.js-cancel-fork-suggestion'), + document.querySelector('.js-file-fork-suggestion-section'), + ); } switch (page) { diff --git a/app/assets/javascripts/files_comment_button.js b/app/assets/javascripts/files_comment_button.js index 3f041172ff3..59d6508fc02 100644 --- a/app/assets/javascripts/files_comment_button.js +++ b/app/assets/javascripts/files_comment_button.js @@ -55,14 +55,19 @@ window.FilesCommentButton = (function() { textFileElement = this.getTextFileElement($currentTarget); buttonParentElement.append(this.buildButton({ + discussionID: lineContentElement.attr('data-discussion-id'), + lineType: lineContentElement.attr('data-line-type'), + noteableType: textFileElement.attr('data-noteable-type'), noteableID: textFileElement.attr('data-noteable-id'), commitID: textFileElement.attr('data-commit-id'), noteType: lineContentElement.attr('data-note-type'), - position: lineContentElement.attr('data-position'), - lineType: lineContentElement.attr('data-line-type'), - discussionID: lineContentElement.attr('data-discussion-id'), - lineCode: lineContentElement.attr('data-line-code') + + // LegacyDiffNote + lineCode: lineContentElement.attr('data-line-code'), + + // DiffNote + position: lineContentElement.attr('data-position') })); }; @@ -76,14 +81,19 @@ window.FilesCommentButton = (function() { FilesCommentButton.prototype.buildButton = function(buttonAttributes) { return $commentButtonTemplate.clone().attr({ + 'data-discussion-id': buttonAttributes.discussionID, + 'data-line-type': buttonAttributes.lineType, + 'data-noteable-type': buttonAttributes.noteableType, 'data-noteable-id': buttonAttributes.noteableID, 'data-commit-id': buttonAttributes.commitID, 'data-note-type': buttonAttributes.noteType, + + // LegacyDiffNote 'data-line-code': buttonAttributes.lineCode, - 'data-position': buttonAttributes.position, - 'data-discussion-id': buttonAttributes.discussionID, - 'data-line-type': buttonAttributes.lineType + + // DiffNote + 'data-position': buttonAttributes.position }); }; @@ -121,7 +131,7 @@ window.FilesCommentButton = (function() { }; FilesCommentButton.prototype.validateLineContent = function(lineContentElement) { - return lineContentElement.attr('data-discussion-id') && lineContentElement.attr('data-discussion-id') !== ''; + return lineContentElement.attr('data-note-type') && lineContentElement.attr('data-note-type') !== ''; }; return FilesCommentButton; diff --git a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js new file mode 100644 index 00000000000..9126422b335 --- /dev/null +++ b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js @@ -0,0 +1,87 @@ +import eventHub from '../event_hub'; + +export default { + name: 'RecentSearchesDropdownContent', + + props: { + items: { + type: Array, + required: true, + }, + }, + + computed: { + processedItems() { + return this.items.map((item) => { + const { tokens, searchToken } + = gl.FilteredSearchTokenizer.processTokens(item); + + const resultantTokens = tokens.map(token => ({ + prefix: `${token.key}:`, + suffix: `${token.symbol}${token.value}`, + })); + + return { + text: item, + tokens: resultantTokens, + searchToken, + }; + }); + }, + hasItems() { + return this.items.length > 0; + }, + }, + + methods: { + onItemActivated(text) { + eventHub.$emit('recentSearchesItemSelected', text); + }, + onRequestClearRecentSearches(e) { + // Stop the dropdown from closing + e.stopPropagation(); + + eventHub.$emit('requestClearRecentSearches'); + }, + }, + + template: ` + <div> + <ul v-if="hasItems"> + <li + v-for="(item, index) in processedItems" + :key="index"> + <button + type="button" + class="filtered-search-history-dropdown-item" + @click="onItemActivated(item.text)"> + <span> + <span + v-for="(token, tokenIndex) in item.tokens" + class="filtered-search-history-dropdown-token"> + <span class="name">{{ token.prefix }}</span><span class="value">{{ token.suffix }}</span> + </span> + </span> + <span class="filtered-search-history-dropdown-search-token"> + {{ item.searchToken }} + </span> + </button> + </li> + <li class="divider"></li> + <li> + <button + type="button" + class="filtered-search-history-clear-button" + @click="onRequestClearRecentSearches($event)"> + Clear recent searches + </button> + </li> + </ul> + <div + v-else + class="dropdown-info-note"> + You don't have any recent searches + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js index ede5d65ce12..79314dd9c6f 100644 --- a/app/assets/javascripts/filtered_search/dropdown_hint.js +++ b/app/assets/javascripts/filtered_search/dropdown_hint.js @@ -55,7 +55,7 @@ require('./filtered_search_dropdown'); renderContent() { const dropdownData = []; - [].forEach.call(this.input.closest('.filtered-search-input-container').querySelectorAll('.dropdown-menu'), (dropdownMenu) => { + [].forEach.call(this.input.closest('.filtered-search-box-input-container').querySelectorAll('.dropdown-menu'), (dropdownMenu) => { const { icon, hint, tag, type } = dropdownMenu.dataset; if (icon && hint && tag) { dropdownData.push( diff --git a/app/assets/javascripts/filtered_search/dropdown_utils.js b/app/assets/javascripts/filtered_search/dropdown_utils.js index 432b0c0dfd2..6c5c20447f7 100644 --- a/app/assets/javascripts/filtered_search/dropdown_utils.js +++ b/app/assets/javascripts/filtered_search/dropdown_utils.js @@ -129,7 +129,9 @@ import FilteredSearchContainer from './container'; } }); - return values.join(' '); + return values + .map(value => value.trim()) + .join(' '); } static getSearchInput(filteredSearchInput) { diff --git a/app/assets/javascripts/filtered_search/event_hub.js b/app/assets/javascripts/filtered_search/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/filtered_search/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 36090b49651..b93a8f1d322 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -1,18 +1,56 @@ +/* global Flash */ + import FilteredSearchContainer from './container'; +import RecentSearchesRoot from './recent_searches_root'; +import RecentSearchesStore from './stores/recent_searches_store'; +import RecentSearchesService from './services/recent_searches_service'; +import eventHub from './event_hub'; (() => { class FilteredSearchManager { constructor(page) { this.container = FilteredSearchContainer.container; this.filteredSearchInput = this.container.querySelector('.filtered-search'); + this.filteredSearchInputForm = this.filteredSearchInput.form; this.clearSearchButton = this.container.querySelector('.clear-search'); this.tokensContainer = this.container.querySelector('.tokens-container'); this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys; + this.recentSearchesStore = new RecentSearchesStore(); + let recentSearchesKey = 'issue-recent-searches'; + if (page === 'merge_requests') { + recentSearchesKey = 'merge-request-recent-searches'; + } + this.recentSearchesService = new RecentSearchesService(recentSearchesKey); + + // Fetch recent searches from localStorage + this.fetchingRecentSearchesPromise = this.recentSearchesService.fetch() + .catch(() => { + // eslint-disable-next-line no-new + new Flash('An error occured while parsing recent searches'); + // Gracefully fail to empty array + return []; + }) + .then((searches) => { + // Put any searches that may have come in before + // we fetched the saved searches ahead of the already saved ones + const resultantSearches = this.recentSearchesStore.setRecentSearches( + this.recentSearchesStore.state.recentSearches.concat(searches), + ); + this.recentSearchesService.save(resultantSearches); + }); + if (this.filteredSearchInput) { this.tokenizer = gl.FilteredSearchTokenizer; this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', page); + this.recentSearchesRoot = new RecentSearchesRoot( + this.recentSearchesStore, + this.recentSearchesService, + document.querySelector('.js-filtered-search-history-dropdown'), + ); + this.recentSearchesRoot.init(); + this.bindEvents(); this.loadSearchParamsFromURL(); this.dropdownManager.setDropdown(); @@ -25,6 +63,10 @@ import FilteredSearchContainer from './container'; cleanup() { this.unbindEvents(); document.removeEventListener('beforeunload', this.cleanupWrapper); + + if (this.recentSearchesRoot) { + this.recentSearchesRoot.destroy(); + } } bindEvents() { @@ -34,7 +76,7 @@ import FilteredSearchContainer from './container'; this.handleInputPlaceholderWrapper = this.handleInputPlaceholder.bind(this); this.handleInputVisualTokenWrapper = this.handleInputVisualToken.bind(this); this.checkForEnterWrapper = this.checkForEnter.bind(this); - this.clearSearchWrapper = this.clearSearch.bind(this); + this.onClearSearchWrapper = this.onClearSearch.bind(this); this.checkForBackspaceWrapper = this.checkForBackspace.bind(this); this.removeSelectedTokenWrapper = this.removeSelectedToken.bind(this); this.unselectEditTokensWrapper = this.unselectEditTokens.bind(this); @@ -42,8 +84,8 @@ import FilteredSearchContainer from './container'; this.tokenChange = this.tokenChange.bind(this); this.addInputContainerFocusWrapper = this.addInputContainerFocus.bind(this); this.removeInputContainerFocusWrapper = this.removeInputContainerFocus.bind(this); + this.onrecentSearchesItemSelectedWrapper = this.onrecentSearchesItemSelected.bind(this); - this.filteredSearchInputForm = this.filteredSearchInput.form; this.filteredSearchInputForm.addEventListener('submit', this.handleFormSubmit); this.filteredSearchInput.addEventListener('input', this.setDropdownWrapper); this.filteredSearchInput.addEventListener('input', this.toggleClearSearchButtonWrapper); @@ -56,11 +98,12 @@ import FilteredSearchContainer from './container'; this.filteredSearchInput.addEventListener('focus', this.addInputContainerFocusWrapper); this.tokensContainer.addEventListener('click', FilteredSearchManager.selectToken); this.tokensContainer.addEventListener('dblclick', this.editTokenWrapper); - this.clearSearchButton.addEventListener('click', this.clearSearchWrapper); + this.clearSearchButton.addEventListener('click', this.onClearSearchWrapper); document.addEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens); document.addEventListener('click', this.unselectEditTokensWrapper); document.addEventListener('click', this.removeInputContainerFocusWrapper); document.addEventListener('keydown', this.removeSelectedTokenWrapper); + eventHub.$on('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper); } unbindEvents() { @@ -76,11 +119,12 @@ import FilteredSearchContainer from './container'; this.filteredSearchInput.removeEventListener('focus', this.addInputContainerFocusWrapper); this.tokensContainer.removeEventListener('click', FilteredSearchManager.selectToken); this.tokensContainer.removeEventListener('dblclick', this.editTokenWrapper); - this.clearSearchButton.removeEventListener('click', this.clearSearchWrapper); + this.clearSearchButton.removeEventListener('click', this.onClearSearchWrapper); document.removeEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens); document.removeEventListener('click', this.unselectEditTokensWrapper); document.removeEventListener('click', this.removeInputContainerFocusWrapper); document.removeEventListener('keydown', this.removeSelectedTokenWrapper); + eventHub.$off('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper); } checkForBackspace(e) { @@ -131,7 +175,7 @@ import FilteredSearchContainer from './container'; } addInputContainerFocus() { - const inputContainer = this.filteredSearchInput.closest('.filtered-search-input-container'); + const inputContainer = this.filteredSearchInput.closest('.filtered-search-box'); if (inputContainer) { inputContainer.classList.add('focus'); @@ -139,7 +183,7 @@ import FilteredSearchContainer from './container'; } removeInputContainerFocus(e) { - const inputContainer = this.filteredSearchInput.closest('.filtered-search-input-container'); + const inputContainer = this.filteredSearchInput.closest('.filtered-search-box'); const isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target); const isElementInDynamicFilterDropdown = e.target.closest('.filter-dropdown') !== null; const isElementInStaticFilterDropdown = e.target.closest('ul[data-dropdown]') !== null; @@ -161,7 +205,7 @@ import FilteredSearchContainer from './container'; } unselectEditTokens(e) { - const inputContainer = this.container.querySelector('.filtered-search-input-container'); + const inputContainer = this.container.querySelector('.filtered-search-box'); const isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target); const isElementInFilterDropdown = e.target.closest('.filter-dropdown') !== null; const isElementTokensContainer = e.target.classList.contains('tokens-container'); @@ -215,9 +259,12 @@ import FilteredSearchContainer from './container'; } } - clearSearch(e) { + onClearSearch(e) { e.preventDefault(); + this.clearSearch(); + } + clearSearch() { this.filteredSearchInput.value = ''; const removeElements = []; @@ -289,6 +336,17 @@ import FilteredSearchContainer from './container'; this.search(); } + saveCurrentSearchQuery() { + // Don't save before we have fetched the already saved searches + this.fetchingRecentSearchesPromise.then(() => { + const searchQuery = gl.DropdownUtils.getSearchQuery(); + if (searchQuery.length > 0) { + const resultantSearches = this.recentSearchesStore.addRecentSearch(searchQuery); + this.recentSearchesService.save(resultantSearches); + } + }); + } + loadSearchParamsFromURL() { const params = gl.utils.getUrlParamsArray(); const usernameParams = this.getUsernameParams(); @@ -343,6 +401,8 @@ import FilteredSearchContainer from './container'; } }); + this.saveCurrentSearchQuery(); + if (hasFilteredSearch) { this.clearSearchButton.classList.remove('hidden'); this.handleInputPlaceholder(); @@ -351,8 +411,12 @@ import FilteredSearchContainer from './container'; search() { const paths = []; + const searchQuery = gl.DropdownUtils.getSearchQuery(); + + this.saveCurrentSearchQuery(); + const { tokens, searchToken } - = this.tokenizer.processTokens(gl.DropdownUtils.getSearchQuery()); + = this.tokenizer.processTokens(searchQuery); const currentState = gl.utils.getParameterByName('state') || 'opened'; paths.push(`state=${currentState}`); @@ -416,6 +480,13 @@ import FilteredSearchContainer from './container'; currentDropdownRef.dispatchInputEvent(); } } + + onrecentSearchesItemSelected(text) { + this.clearSearch(); + this.filteredSearchInput.value = text; + this.filteredSearchInput.dispatchEvent(new CustomEvent('input')); + this.search(); + } } window.gl = window.gl || {}; diff --git a/app/assets/javascripts/filtered_search/recent_searches_root.js b/app/assets/javascripts/filtered_search/recent_searches_root.js new file mode 100644 index 00000000000..4e38409e12a --- /dev/null +++ b/app/assets/javascripts/filtered_search/recent_searches_root.js @@ -0,0 +1,59 @@ +import Vue from 'vue'; +import RecentSearchesDropdownContent from './components/recent_searches_dropdown_content'; +import eventHub from './event_hub'; + +class RecentSearchesRoot { + constructor( + recentSearchesStore, + recentSearchesService, + wrapperElement, + ) { + this.store = recentSearchesStore; + this.service = recentSearchesService; + this.wrapperElement = wrapperElement; + } + + init() { + this.bindEvents(); + this.render(); + } + + bindEvents() { + this.onRequestClearRecentSearchesWrapper = this.onRequestClearRecentSearches.bind(this); + + eventHub.$on('requestClearRecentSearches', this.onRequestClearRecentSearchesWrapper); + } + + unbindEvents() { + eventHub.$off('requestClearRecentSearches', this.onRequestClearRecentSearchesWrapper); + } + + render() { + this.vm = new Vue({ + el: this.wrapperElement, + data: this.store.state, + template: ` + <recent-searches-dropdown-content + :items="recentSearches" /> + `, + components: { + 'recent-searches-dropdown-content': RecentSearchesDropdownContent, + }, + }); + } + + onRequestClearRecentSearches() { + const resultantSearches = this.store.setRecentSearches([]); + this.service.save(resultantSearches); + } + + destroy() { + this.unbindEvents(); + if (this.vm) { + this.vm.$destroy(); + } + } + +} + +export default RecentSearchesRoot; diff --git a/app/assets/javascripts/filtered_search/services/recent_searches_service.js b/app/assets/javascripts/filtered_search/services/recent_searches_service.js new file mode 100644 index 00000000000..3e402d5aed0 --- /dev/null +++ b/app/assets/javascripts/filtered_search/services/recent_searches_service.js @@ -0,0 +1,26 @@ +class RecentSearchesService { + constructor(localStorageKey = 'issuable-recent-searches') { + this.localStorageKey = localStorageKey; + } + + fetch() { + const input = window.localStorage.getItem(this.localStorageKey); + + let searches = []; + if (input && input.length > 0) { + try { + searches = JSON.parse(input); + } catch (err) { + return Promise.reject(err); + } + } + + return Promise.resolve(searches); + } + + save(searches = []) { + window.localStorage.setItem(this.localStorageKey, JSON.stringify(searches)); + } +} + +export default RecentSearchesService; diff --git a/app/assets/javascripts/filtered_search/stores/recent_searches_store.js b/app/assets/javascripts/filtered_search/stores/recent_searches_store.js new file mode 100644 index 00000000000..066be69766a --- /dev/null +++ b/app/assets/javascripts/filtered_search/stores/recent_searches_store.js @@ -0,0 +1,23 @@ +import _ from 'underscore'; + +class RecentSearchesStore { + constructor(initialState = {}) { + this.state = Object.assign({ + recentSearches: [], + }, initialState); + } + + addRecentSearch(newSearch) { + this.setRecentSearches([newSearch].concat(this.state.recentSearches)); + + return this.state.recentSearches; + } + + setRecentSearches(searches = []) { + const trimmedSearches = searches.map(search => search.trim()); + this.state.recentSearches = _.uniq(trimmedSearches).slice(0, 5); + return this.state.recentSearches; + } +} + +export default RecentSearchesStore; diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index e7c98e16581..ff10f19a4fe 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -29,7 +29,8 @@ GLForm.prototype.setupForm = function() { this.form.find('.div-dropzone').remove(); this.form.addClass('gfm-form'); // remove notify commit author checkbox for non-commit notes - gl.utils.disableButtonIfEmptyField(this.form.find('.js-note-text'), this.form.find('.js-comment-button')); + gl.utils.disableButtonIfEmptyField(this.form.find('.js-note-text'), this.form.find('.js-comment-button, .js-note-new-discussion')); + gl.GfmAutoComplete.setup(this.form.find('.js-gfm-input')); new DropzoneInput(this.form); autosize(this.textarea); diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 177cf66b37d..3f92d4ee6cf 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -279,7 +279,7 @@ $(function () { // Disable form buttons while a form is submitting $body.on('ajax:complete, ajax:beforeSend, submit', 'form', function (e) { var buttons; - buttons = $('[type="submit"]', this); + buttons = $('[type="submit"], .js-disable-on-submit', this); switch (e.type) { case 'ajax:beforeSend': case 'submit': diff --git a/app/assets/javascripts/monitoring/prometheus_graph.js b/app/assets/javascripts/monitoring/prometheus_graph.js index a6ffa0f59de..d82a4eb9642 100644 --- a/app/assets/javascripts/monitoring/prometheus_graph.js +++ b/app/assets/javascripts/monitoring/prometheus_graph.js @@ -6,7 +6,10 @@ import statusCodes from '~/lib/utils/http_status'; import { formatRelevantDigits } from '~/lib/utils/number_utils'; import '../flash'; +const prometheusContainer = '.prometheus-container'; +const prometheusParentGraphContainer = '.prometheus-graphs'; const prometheusGraphsContainer = '.prometheus-graph'; +const prometheusStatesContainer = '.prometheus-state'; const metricsEndpoint = 'metrics.json'; const timeFormat = d3.time.format('%H:%M'); const dayFormat = d3.time.format('%b %e, %a'); @@ -14,19 +17,30 @@ const bisectDate = d3.bisector(d => d.time).left; const extraAddedWidthParent = 100; class PrometheusGraph { - constructor() { - this.margin = { top: 80, right: 180, bottom: 80, left: 100 }; - this.marginLabelContainer = { top: 40, right: 0, bottom: 40, left: 0 }; - const parentContainerWidth = $(prometheusGraphsContainer).parent().width() + - extraAddedWidthParent; - this.originalWidth = parentContainerWidth; - this.originalHeight = 330; - this.width = parentContainerWidth - this.margin.left - this.margin.right; - this.height = this.originalHeight - this.margin.top - this.margin.bottom; - this.backOffRequestCounter = 0; - this.configureGraph(); - this.init(); + const $prometheusContainer = $(prometheusContainer); + const hasMetrics = $prometheusContainer.data('has-metrics'); + this.docLink = $prometheusContainer.data('doc-link'); + this.integrationLink = $prometheusContainer.data('prometheus-integration'); + + $(document).ajaxError(() => {}); + + if (hasMetrics) { + this.margin = { top: 80, right: 180, bottom: 80, left: 100 }; + this.marginLabelContainer = { top: 40, right: 0, bottom: 40, left: 0 }; + const parentContainerWidth = $(prometheusGraphsContainer).parent().width() + + extraAddedWidthParent; + this.originalWidth = parentContainerWidth; + this.originalHeight = 330; + this.width = parentContainerWidth - this.margin.left - this.margin.right; + this.height = this.originalHeight - this.margin.top - this.margin.bottom; + this.backOffRequestCounter = 0; + this.configureGraph(); + this.init(); + } else { + this.state = '.js-getting-started'; + this.updateState(); + } } createGraph() { @@ -40,8 +54,19 @@ class PrometheusGraph { init() { this.getData().then((metricsResponse) => { - if (Object.keys(metricsResponse).length === 0) { - new Flash('Empty metrics', 'alert'); + let enoughData = true; + Object.keys(metricsResponse.metrics).forEach((key) => { + let currentKey; + if (key === 'cpu_values' || key === 'memory_values') { + currentKey = metricsResponse.metrics[key]; + if (Object.keys(currentKey).length === 0) { + enoughData = false; + } + } + }); + if (!enoughData) { + this.state = '.js-loading'; + this.updateState(); } else { this.transformData(metricsResponse); this.createGraph(); @@ -345,14 +370,17 @@ class PrometheusGraph { } return resp.metrics; }) - .catch(() => new Flash('An error occurred while fetching metrics.', 'alert')); + .catch(() => { + this.state = '.js-unable-to-connect'; + this.updateState(); + }); } transformData(metricsResponse) { Object.keys(metricsResponse.metrics).forEach((key) => { if (key === 'cpu_values' || key === 'memory_values') { const metricValues = (metricsResponse.metrics[key])[0]; - if (typeof metricValues !== 'undefined') { + if (metricValues !== undefined) { this.graphSpecificProperties[key].data = metricValues.values.map(metric => ({ time: new Date(metric[0] * 1000), value: metric[1], @@ -361,6 +389,13 @@ class PrometheusGraph { } }); } + + updateState() { + const $statesContainer = $(prometheusStatesContainer); + $(prometheusParentGraphContainer).hide(); + $(`${this.state}`, $statesContainer).removeClass('hidden'); + $(prometheusStatesContainer).show(); + } } export default PrometheusGraph; diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 1d563c63f39..66e89b7607a 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -5,6 +5,7 @@ /* global mrRefreshWidgetUrl */ import Cookies from 'js-cookie'; +import CommentTypeToggle from './comment_type_toggle'; require('./autosave'); window.autosize = require('vendor/autosize'); @@ -110,7 +111,6 @@ require('./task_list'); $(document).on("visibilitychange", this.visibilityChange); // when issue status changes, we need to refresh data $(document).on("issuable:change", this.refresh); - // when a key is clicked on the notes return $(document).on("keydown", ".js-note-text", this.keydownNoteText); }; @@ -137,6 +137,18 @@ require('./task_list'); $(document).off("click", '.system-note-commit-list-toggler'); }; + Notes.prototype.initCommentTypeToggle = function (form) { + this.commentTypeToggle = new CommentTypeToggle( + form.querySelector('.js-comment-type-dropdown .dropdown-toggle'), + form.querySelector('.js-comment-type-dropdown .dropdown-menu'), + form.querySelector('#note_type'), + form.querySelector('.js-comment-type-dropdown .js-comment-submit-button'), + form.querySelector('.js-note-target-close'), + ); + + this.commentTypeToggle.initDroplab(); + }; + Notes.prototype.keydownNoteText = function(e) { var $textarea, discussionNoteForm, editNote, myLastNote, myLastNoteEditBtn, newText, originalText; if (gl.utils.isMetaKey(e)) { @@ -192,7 +204,7 @@ require('./task_list'); }; Notes.prototype.refresh = function() { - if (!document.hidden && document.URL.indexOf(this.noteable_url) === 0) { + if (!document.hidden) { return this.getContent(); } }; @@ -213,11 +225,7 @@ require('./task_list'); _this.last_fetched_at = data.last_fetched_at; _this.setPollingInterval(data.notes.length); return $.each(notes, function(i, note) { - if (note.discussion_html != null) { - return _this.renderDiscussionNote(note); - } else { - return _this.renderNote(note); - } + _this.renderNote(note); }); }; })(this) @@ -276,8 +284,12 @@ require('./task_list'); Note: for rendering inline notes use renderDiscussionNote */ - Notes.prototype.renderNote = function(note) { + Notes.prototype.renderNote = function(note, $form) { var $notesList; + if (note.discussion_html != null) { + return this.renderDiscussionNote(note, $form); + } + if (!note.valid) { if (note.errors.commands_only) { new Flash(note.errors.commands_only, 'notice', this.parentTimeline); @@ -317,16 +329,13 @@ require('./task_list'); Note: for rendering inline notes use renderDiscussionNote */ - Notes.prototype.renderDiscussionNote = function(note) { + Notes.prototype.renderDiscussionNote = function(note, $form) { var discussionContainer, form, note_html, row, lineType, diffAvatarContainer; if (!this.isNewNote(note)) { return; } this.note_ids.push(note.id); - form = $("#new-discussion-note-form-" + note.discussion_id); - if ((note.original_discussion_id != null) && form.length === 0) { - form = $("#new-discussion-note-form-" + note.original_discussion_id); - } + form = $form || $(".js-discussion-note-form[data-discussion-id='" + note.discussion_id + "']"); row = form.closest("tr"); lineType = this.isParallelView() ? form.find('#line_type').val() : 'old'; diffAvatarContainer = row.prevAll('.line_holder').first().find('.js-avatar-container.' + lineType + '_line'); @@ -334,8 +343,8 @@ require('./task_list'); note_html.renderGFM(); // is this the first note of discussion? discussionContainer = $(".notes[data-discussion-id='" + note.discussion_id + "']"); - if ((note.original_discussion_id != null) && discussionContainer.length === 0) { - discussionContainer = $(".notes[data-discussion-id='" + note.original_discussion_id + "']"); + if (!discussionContainer.length) { + discussionContainer = form.closest('.discussion').find('.notes'); } if (discussionContainer.length === 0) { if (!this.isParallelView() || row.hasClass('js-temp-notes-holder')) { @@ -363,7 +372,7 @@ require('./task_list'); // Add note to 'Changes' page discussions discussionContainer.append(note_html); // Init discussion on 'Discussion' page if it is merge request page - if ($('body').attr('data-page').indexOf('projects:merge_request') === 0) { + if ($('body').attr('data-page').indexOf('projects:merge_request') === 0 || !note.diff_discussion_html) { $('ul.main-notes-list').append(note.discussion_html).renderGFM(); } } else { @@ -371,7 +380,7 @@ require('./task_list'); discussionContainer.append(note_html); } - if (typeof gl.diffNotesCompileComponents !== 'undefined' && note.discussion_id) { + if (typeof gl.diffNotesCompileComponents !== 'undefined' && note.discussion_resolvable) { gl.diffNotesCompileComponents(); this.renderDiscussionAvatar(diffAvatarContainer, note); } @@ -455,9 +464,14 @@ require('./task_list'); form.addClass("js-main-target-form"); form.find("#note_line_code").remove(); form.find("#note_position").remove(); - form.find("#note_type").remove(); + form.find("#note_type").val(''); + form.find("#in_reply_to_discussion_id").remove(); form.find('.js-comment-resolve-button').closest('comment-and-resolve-btn').remove(); - return this.parentTimeline = form.parents('.timeline'); + this.parentTimeline = form.parents('.timeline'); + + if (form.length) { + this.initCommentTypeToggle(form.get(0)); + } }; /* @@ -470,10 +484,24 @@ require('./task_list'); */ Notes.prototype.setupNoteForm = function(form) { - var textarea; + var textarea, key; new gl.GLForm(form); textarea = form.find(".js-note-text"); - return new Autosave(textarea, ["Note", form.find("#note_noteable_type").val(), form.find("#note_noteable_id").val(), form.find("#note_commit_id").val(), form.find("#note_type").val(), form.find("#note_line_code").val(), form.find("#note_position").val()]); + key = [ + "Note", + form.find("#note_noteable_type").val(), + form.find("#note_noteable_id").val(), + form.find("#note_commit_id").val(), + form.find("#note_type").val(), + form.find("#in_reply_to_discussion_id").val(), + + // LegacyDiffNote + form.find("#note_line_code").val(), + + // DiffNote + form.find("#note_position").val() + ]; + return new Autosave(textarea, key); }; /* @@ -510,7 +538,7 @@ require('./task_list'); } } - this.renderDiscussionNote(note); + this.renderNote(note, $form); // cleanup after successfully creating a diff/discussion note this.removeDiscussionNoteForm($form); }; @@ -656,7 +684,7 @@ require('./task_list'); return function(i, el) { var note, notes; note = $(el); - notes = note.closest(".notes"); + notes = note.closest(".discussion-notes"); if (typeof gl.diffNotesCompileComponents !== 'undefined') { if (gl.diffNoteApps[noteElId]) { @@ -673,14 +701,13 @@ require('./task_list'); // "Discussions" tab notes.closest(".timeline-entry").remove(); - if (!_this.isParallelView() || notesTr.find('.note').length === 0) { - // "Changes" tab / commit view - notesTr.remove(); + // The notes tr can contain multiple lists of notes, like on the parallel diff + if (notesTr.find('.discussion-notes').length > 1) { + notes.remove(); } else { - notes.closest('.content').empty(); + notesTr.remove(); } } - return note.remove(); }; })(this)); // Decrement the "Discussions" counter only once @@ -711,7 +738,7 @@ require('./task_list'); Notes.prototype.replyToDiscussionNote = function(e) { var form, replyLink; - form = this.formClone.clone(); + form = this.cleanForm(this.formClone.clone()); replyLink = $(e.target).closest(".js-discussion-reply-button"); // insert the form after the button replyLink @@ -727,29 +754,44 @@ require('./task_list'); Sets some hidden fields in the form. - Note: dataHolder must have the "discussionId", "lineCode", "noteableType" - and "noteableId" data attributes set. + Note: dataHolder must have the "discussionId" and "lineCode" data attributes set. */ Notes.prototype.setupDiscussionNoteForm = function(dataHolder, form) { // setup note target - form.attr('id', "new-discussion-note-form-" + (dataHolder.data("discussionId"))); + var discussionID = dataHolder.data("discussionId"); + + if (discussionID) { + form.attr("data-discussion-id", discussionID); + form.find("#in_reply_to_discussion_id").val(discussionID); + } + form.attr("data-line-code", dataHolder.data("lineCode")); - form.find("#note_type").val(dataHolder.data("noteType")); form.find("#line_type").val(dataHolder.data("lineType")); + + form.find("#note_noteable_type").val(dataHolder.data("noteableType")); + form.find("#note_noteable_id").val(dataHolder.data("noteableId")); form.find("#note_commit_id").val(dataHolder.data("commitId")); + form.find("#note_type").val(dataHolder.data("noteType")); + + // LegacyDiffNote form.find("#note_line_code").val(dataHolder.data("lineCode")); + + // DiffNote form.find("#note_position").val(dataHolder.attr("data-position")); - form.find("#note_noteable_type").val(dataHolder.data("noteableType")); - form.find("#note_noteable_id").val(dataHolder.data("noteableId")); + form.find('.js-note-discard').show().removeClass('js-note-discard').addClass('js-close-discussion-note-form').text(form.find('.js-close-discussion-note-form').data('cancel-text')); form.find('.js-note-target-close').remove(); + form.find('.js-note-new-discussion').remove(); this.setupNoteForm(form); + form + .removeClass('js-main-target-form') + .addClass("discussion-form js-discussion-note-form"); + if (typeof gl.diffNotesCompileComponents !== 'undefined') { var $commentBtn = form.find('comment-and-resolve-btn'); - $commentBtn - .attr(':discussion-id', "'" + dataHolder.data('discussionId') + "'"); + $commentBtn.attr(':discussion-id', "'" + discussionID + "'"); gl.diffNotesCompileComponents(); } @@ -757,10 +799,7 @@ require('./task_list'); form.find(".js-note-text").focus(); form .find('.js-comment-resolve-button') - .attr('data-discussion-id', dataHolder.data('discussionId')); - form - .removeClass('js-main-target-form') - .addClass("discussion-form js-discussion-note-form"); + .attr('data-discussion-id', discussionID); }; /* @@ -823,7 +862,7 @@ require('./task_list'); } if (addForm) { - newForm = this.formClone.clone(); + newForm = this.cleanForm(this.formClone.clone()); newForm.appendTo(notesContent); // show the form return this.setupDiscussionNoteForm($link, newForm); @@ -902,7 +941,7 @@ require('./task_list'); discardbtn = form.find('.js-note-discard'); if (textarea.val().trim().length > 0) { reopentext = reopenbtn.data('alternative-text'); - closetext = closebtn.data('alternative-text'); + closetext = closebtn.attr('data-alternative-text'); if (reopenbtn.text() !== reopentext) { reopenbtn.text(reopentext); } @@ -1009,6 +1048,20 @@ require('./task_list'); }); }; + Notes.prototype.cleanForm = function($form) { + // Remove JS classes that are not needed here + $form + .find('.js-comment-type-dropdown') + .removeClass('btn-group'); + + // Remove dropdown + $form + .find('.dropdown-menu') + .remove(); + + return $form; + }; + return Notes; })(); }).call(window); diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss index 1ae144fb471..b849cc2d853 100644 --- a/app/assets/stylesheets/framework/awards.scss +++ b/app/assets/stylesheets/framework/awards.scss @@ -91,7 +91,7 @@ .award-menu-holder { display: inline-block; - position: relative; + position: absolute; .tooltip { white-space: nowrap; @@ -117,11 +117,41 @@ &.active, &:hover, - &:active { + &:active, + &.is-active { background-color: $row-hover; border-color: $row-hover-border; box-shadow: none; outline: 0; + + .award-control-icon svg { + background: $award-emoji-positive-add-bg; + + path { + fill: $award-emoji-positive-add-lines; + } + } + + .award-control-icon-neutral { + opacity: 0; + } + + .award-control-icon-positive { + opacity: 1; + transform: scale(1.15); + } + } + + &.is-active { + .award-control-icon-positive { + opacity: 0; + transform: scale(1); + } + + .award-control-icon-super-positive { + opacity: 1; + transform: scale(1); + } } &.btn { @@ -162,9 +192,33 @@ color: $border-gray-normal; margin-top: 1px; padding: 0 2px; + + svg { + margin-bottom: 1px; + height: 18px; + width: 18px; + border-radius: 50%; + + path { + fill: $border-gray-normal; + } + } + } + + .award-control-icon-positive, + .award-control-icon-super-positive { + position: absolute; + left: 7px; + bottom: 9px; + opacity: 0; + @include transition(opacity, transform); } .award-control-text { vertical-align: middle; } } + +.note-awards .award-control-icon-positive { + left: 6px; +} diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 2ede47e9de6..23cba57f83a 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -177,10 +177,6 @@ border-radius: $border-radius-base; box-shadow: 0 2px 4px $dropdown-shadow-color; - .filtered-search-input-container & { - max-width: 280px; - } - &.is-loading { .dropdown-content { display: none; @@ -467,6 +463,11 @@ overflow-y: auto; } +.dropdown-info-note { + color: $gl-text-color-secondary; + text-align: center; +} + .dropdown-footer { padding-top: 10px; margin-top: 10px; diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index ddea1cf540b..a5a8522739e 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -281,3 +281,16 @@ span.idiff { display: none; } } + +.file-fork-suggestion { + display: flex; + align-items: center; + justify-content: flex-end; + background-color: $gray-light; + border-bottom: 1px solid $border-color; + padding: 5px $gl-padding; +} + +.file-fork-suggestion-note { + margin-right: 1.5em; +} diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 5a034e11206..8be2e3fc70c 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -22,7 +22,6 @@ } @media (min-width: $screen-sm-min) { - .issues-filters, .issues_bulk_update { .dropdown-menu-toggle { width: 132px; @@ -56,7 +55,7 @@ } } -.filtered-search-container { +.filtered-search-wrapper { display: -webkit-flex; display: flex; @@ -151,11 +150,13 @@ width: 100%; } -.filtered-search-input-container { +.filtered-search-box { + position: relative; + flex: 1; display: -webkit-flex; display: flex; - position: relative; width: 100%; + min-width: 0; border: 1px solid $border-color; background-color: $white-light; @@ -163,14 +164,6 @@ -webkit-flex: 1 1 auto; flex: 1 1 auto; margin-bottom: 10px; - - .dropdown-menu { - width: auto; - left: 0; - right: 0; - max-width: none; - min-width: 100%; - } } &:hover { @@ -229,6 +222,118 @@ } } +.filtered-search-box-input-container { + flex: 1; + position: relative; + // Fix PhantomJS not supporting `flex: 1;` properly. + // This is important because it can change the expected `e.target` when clicking things in tests. + // See https://gitlab.com/gitlab-org/gitlab-ce/blob/b54acba8b732688c59fe2f38510c469dc86ee499/spec/features/issues/filtered_search/visual_tokens_spec.rb#L61 + // - With `width: 100%`: `e.target` = `.tokens-container`, https://i.imgur.com/jGq7wbx.png + // - Without `width: 100%`: `e.target` = `.filtered-search`, https://i.imgur.com/cNI2CyT.png + width: 100%; + min-width: 0; +} + +.filtered-search-input-dropdown-menu { + max-width: 280px; + + @media (max-width: $screen-xs-min) { + width: auto; + left: 0; + right: 0; + max-width: none; + min-width: 100%; + } +} + +.filtered-search-history-dropdown-toggle-button { + display: flex; + align-items: center; + width: auto; + height: 100%; + padding-top: 0; + padding-left: 0.75em; + padding-bottom: 0; + padding-right: 0.5em; + + background-color: transparent; + border-radius: 0; + border-top: 0; + border-left: 0; + border-bottom: 0; + border-right: 1px solid $border-color; + + color: $gl-text-color-secondary; + + transition: color 0.1s linear; + + &:hover, + &:focus { + color: $gl-text-color; + border-color: $dropdown-input-focus-border; + outline: none; + } + + .dropdown-toggle-text { + color: inherit; + + .fa { + color: inherit; + } + } + + .fa { + position: initial; + } + +} + +.filtered-search-history-dropdown-wrapper { + position: initial; + flex-shrink: 0; +} + +.filtered-search-history-dropdown { + width: 40%; + + @media (max-width: $screen-xs-min) { + left: 0; + right: 0; + max-width: none; + } +} + +.filtered-search-history-dropdown-content { + max-height: none; +} + +.filtered-search-history-dropdown-item, +.filtered-search-history-clear-button { + @include dropdown-link; + + overflow: hidden; + width: 100%; + margin: 0.5em 0; + + background-color: transparent; + border: 0; + text-align: left; + white-space: nowrap; + text-overflow: ellipsis; +} + +.filtered-search-history-dropdown-token { + display: inline; + + &:not(:last-child) { + margin-right: 0.3em; + } + + & > .value { + font-weight: 600; + } +} + .filter-dropdown-container { display: -webkit-flex; display: flex; @@ -248,10 +353,8 @@ } @media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) { - .issues-details-filters { - .dropdown-menu-toggle { - width: 100px; - } + .issue-bulk-update-dropdown-toggle { + width: 100px; } } diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss index 8cd49280e1c..7098203321d 100644 --- a/app/assets/stylesheets/framework/modal.scss +++ b/app/assets/stylesheets/framework/modal.scss @@ -16,6 +16,8 @@ body.modal-open { overflow: hidden; } -.modal .modal-dialog { - width: 860px; +@media (min-width: $screen-md-min) { + .modal-dialog { + width: 860px; + } } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 97794a47df8..712eb7caf33 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -293,6 +293,8 @@ $badge-color: $gl-text-color-secondary; * Award emoji */ $award-emoji-menu-shadow: rgba(0,0,0,.175); +$award-emoji-positive-add-bg: #fed159; +$award-emoji-positive-add-lines: #bb9c13; /* * Search Box diff --git a/app/assets/stylesheets/pages/container_registry.scss b/app/assets/stylesheets/pages/container_registry.scss new file mode 100644 index 00000000000..3266714396e --- /dev/null +++ b/app/assets/stylesheets/pages/container_registry.scss @@ -0,0 +1,16 @@ +/** + * Container Registry + */ + +.container-image { + border-bottom: 1px solid $white-normal; +} + +.container-image-head { + padding: 0 16px; + line-height: 4em; +} + +.table.tags { + margin-bottom: 0; +} diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index 6faa3794c83..72e7d42858d 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -233,6 +233,15 @@ stroke-width: 1; } +.prometheus-state { + margin-top: 10px; + display: none; + + .state-button-section { + margin-top: 10px; + } +} + .environments-actions { .external-url, .monitoring-url, diff --git a/app/assets/stylesheets/pages/events.scss b/app/assets/stylesheets/pages/events.scss index 08398bb43a2..e7f9bbbc62f 100644 --- a/app/assets/stylesheets/pages/events.scss +++ b/app/assets/stylesheets/pages/events.scss @@ -4,14 +4,14 @@ */ .event-item { font-size: $gl-font-size; - padding: $gl-padding-top 0 $gl-padding-top ($gl-avatar-size + $gl-padding-top); + padding: $gl-padding-top 0 $gl-padding-top 40px; border-bottom: 1px solid $white-normal; color: $list-text-color; + position: relative; &.event-inline { - .avatar { - position: relative; - top: -2px; + .profile-icon { + top: 20px; } .event-title, @@ -24,8 +24,28 @@ color: $gl-text-color; } - .avatar { - margin-left: -($gl-avatar-size + $gl-padding-top); + .profile-icon { + position: absolute; + left: 0; + top: 14px; + + svg { + width: 20px; + height: auto; + fill: $gl-text-color-secondary; + } + + &.open-icon svg { + fill: $green-300; + } + + &.closed-icon svg { + fill: $red-300; + } + + &.fork-icon svg { + fill: $blue-300; + } } .event-title { @@ -163,7 +183,7 @@ max-width: 100%; } - .avatar { + .profile-icon { display: none; } diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 566dcc64802..2f946ab2f59 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -329,8 +329,6 @@ } #modal_merge_info .modal-dialog { - width: 600px; - .dark { margin-right: 40px; } diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 927bf9805ce..09c178c5a14 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -310,3 +310,58 @@ margin-bottom: 10px; } } + +.comment-type-dropdown { + .dropdown-toggle .fa { + color: $white-light; + padding-right: 2px; + margin-top: 2px; + pointer-events: none; + } + + .dropdown-menu { + top: initial; + bottom: 40px; + width: 298px; + } + + .description { + display: inline-block; + white-space: normal; + margin-left: 8px; + padding-right: 33px; + } + + li { + white-space: nowrap; + border-radius: 0; + cursor: pointer; + padding-top: 6px; + + &:hover, + &:focus { + background-color: $dropdown-hover-color; + color: $white-light; + } + + &.droplab-item-selected i { + visibility: visible; + } + + i { + visibility: hidden; + } + } + + i { + display: inline-block; + vertical-align: top; + padding-top: 2px; + } + + .divider { + margin: 0 8px; + padding: 0; + border-top: $gray-darkest; + } +} diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 57cf8e136e2..191e3ded8c3 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -294,6 +294,18 @@ ul.notes { border-width: 1px; } + .discussion-notes { + &:not(:first-child) { + border-top: 1px solid $white-normal; + margin-top: 20px; + } + + &:not(:last-child) { + border-bottom: 1px solid $white-normal; + margin-bottom: 20px; + } + } + .notes { background-color: $white-light; } @@ -398,13 +410,50 @@ ul.notes { font-size: 17px; } - &:hover { + svg { + height: 16px; + width: 16px; + fill: $gray-darkest; + vertical-align: text-top; + } + + .award-control-icon-positive, + .award-control-icon-super-positive { + position: absolute; + margin-left: -20px; + opacity: 0; + } + + &:hover, + &.is-active { .danger-highlight { color: $gl-text-red; } .link-highlight { color: $gl-link-color; + + svg { + fill: $gl-link-color; + } + } + + .award-control-icon-neutral { + opacity: 0; + } + + .award-control-icon-positive { + opacity: 1; + } + } + + &.is-active { + .award-control-icon-positive { + opacity: 0; + } + + .award-control-icon-super-positive { + opacity: 1; } } } @@ -508,7 +557,6 @@ ul.notes { } .line-resolve-all-container { - .btn-group { margin-left: -4px; } @@ -537,7 +585,6 @@ ul.notes { fill: $gray-darkest; } } - } .line-resolve-all { diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 703c5fc8869..8c6dd392865 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -230,6 +230,14 @@ font-size: 0; } + .fade-right { + right: 0; + } + + .fade-left { + left: 0; + } + @media (max-width: $screen-xs-max) { .cover-block { padding-top: 20px; diff --git a/app/assets/stylesheets/pages/settings_ci_cd.scss b/app/assets/stylesheets/pages/settings_ci_cd.scss index b97a29cd1a0..fe22d186af1 100644 --- a/app/assets/stylesheets/pages/settings_ci_cd.scss +++ b/app/assets/stylesheets/pages/settings_ci_cd.scss @@ -6,6 +6,8 @@ } .trigger-actions { + white-space: nowrap; + .btn { margin-left: 10px; } diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss index fc4da4c495f..f3916622b6f 100644 --- a/app/assets/stylesheets/pages/tree.scss +++ b/app/assets/stylesheets/pages/tree.scss @@ -145,8 +145,6 @@ margin: 0; } -#modal-remove-blob > .modal-dialog { width: 850px; } - .blob-upload-dropzone-previews { text-align: center; border: 2px; diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index cea3d088e94..f28bbdeff5a 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -72,7 +72,9 @@ class Admin::GroupsController < Admin::ApplicationController :name, :path, :request_access_enabled, - :visibility_level + :visibility_level, + :require_two_factor_authentication, + :two_factor_grace_period ] end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 6a6e335d314..e77094fe2a8 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -8,12 +8,12 @@ class ApplicationController < ActionController::Base include PageLayoutHelper include SentryHelper include WorkhorseHelper + include EnforcesTwoFactorAuthentication before_action :authenticate_user_from_private_token! before_action :authenticate_user! before_action :validate_user_service_ticket! before_action :check_password_expiration - before_action :check_2fa_requirement before_action :ldap_security_check before_action :sentry_context before_action :default_headers @@ -151,12 +151,6 @@ class ApplicationController < ActionController::Base end end - def check_2fa_requirement - if two_factor_authentication_required? && current_user && !current_user.two_factor_enabled? && !skip_two_factor? - redirect_to profile_two_factor_auth_path - end - end - def ldap_security_check if current_user && current_user.requires_ldap_check? return unless current_user.try_obtain_ldap_lease @@ -265,23 +259,6 @@ class ApplicationController < ActionController::Base current_application_settings.import_sources.include?('gitlab_project') end - def two_factor_authentication_required? - current_application_settings.require_two_factor_authentication - end - - def two_factor_grace_period - current_application_settings.two_factor_grace_period - end - - def two_factor_grace_period_expired? - date = current_user.otp_grace_period_started_at - date && (date + two_factor_grace_period.hours) < Time.current - end - - def skip_two_factor? - session[:skip_tfa] && session[:skip_tfa] > Time.current - end - # U2F (universal 2nd factor) devices need a unique identifier for the application # to perform authentication. # https://developers.yubico.com/U2F/App_ID.html diff --git a/app/controllers/concerns/enforces_two_factor_authentication.rb b/app/controllers/concerns/enforces_two_factor_authentication.rb new file mode 100644 index 00000000000..688e8bd4a37 --- /dev/null +++ b/app/controllers/concerns/enforces_two_factor_authentication.rb @@ -0,0 +1,58 @@ +# == EnforcesTwoFactorAuthentication +# +# Controller concern to enforce two-factor authentication requirements +# +# Upon inclusion, adds `check_two_factor_requirement` as a before_action, +# and makes `two_factor_grace_period_expired?` and `two_factor_skippable?` +# available as view helpers. +module EnforcesTwoFactorAuthentication + extend ActiveSupport::Concern + + included do + before_action :check_two_factor_requirement + helper_method :two_factor_grace_period_expired?, :two_factor_skippable? + end + + def check_two_factor_requirement + if two_factor_authentication_required? && current_user && !current_user.two_factor_enabled? && !skip_two_factor? + redirect_to profile_two_factor_auth_path + end + end + + def two_factor_authentication_required? + current_application_settings.require_two_factor_authentication? || + current_user.try(:require_two_factor_authentication_from_group?) + end + + def two_factor_authentication_reason(global: -> {}, group: -> {}) + if two_factor_authentication_required? + if current_application_settings.require_two_factor_authentication? + global.call + else + groups = current_user.expanded_groups_requiring_two_factor_authentication.reorder(name: :asc) + group.call(groups) + end + end + end + + def two_factor_grace_period + periods = [current_application_settings.two_factor_grace_period] + periods << current_user.two_factor_grace_period if current_user.try(:require_two_factor_authentication_from_group?) + periods.min + end + + def two_factor_grace_period_expired? + date = current_user.otp_grace_period_started_at + date && (date + two_factor_grace_period.hours) < Time.current + end + + def two_factor_skippable? + two_factor_authentication_required? && + !current_user.two_factor_enabled? && + !two_factor_grace_period_expired? + end + + def skip_two_factor? + session[:skip_two_factor] && session[:skip_two_factor] > Time.current + end +end diff --git a/app/controllers/concerns/renders_notes.rb b/app/controllers/concerns/renders_notes.rb new file mode 100644 index 00000000000..dd21066ac13 --- /dev/null +++ b/app/controllers/concerns/renders_notes.rb @@ -0,0 +1,20 @@ +module RendersNotes + def prepare_notes_for_rendering(notes) + preload_noteable_for_regular_notes(notes) + preload_max_access_for_authors(notes, @project) + Banzai::NoteRenderer.render(notes, @project, current_user) + + notes + end + + private + + def preload_max_access_for_authors(notes, project) + user_ids = notes.map(&:author_id) + project.team.max_member_access_for_user_ids(user_ids) + end + + def preload_noteable_for_regular_notes(notes) + ActiveRecord::Associations::Preloader.new.preload(notes.reject(&:for_commit?), :noteable) + end +end diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 78c9f1f7004..593001e6396 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -151,7 +151,9 @@ class GroupsController < Groups::ApplicationController :visibility_level, :parent_id, :create_chat_team, - :chat_team_name + :chat_team_name, + :require_two_factor_authentication, + :two_factor_grace_period ] end diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb index 26e7e93533e..d3fa81cd623 100644 --- a/app/controllers/profiles/two_factor_auths_controller.rb +++ b/app/controllers/profiles/two_factor_auths_controller.rb @@ -1,5 +1,5 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController - skip_before_action :check_2fa_requirement + skip_before_action :check_two_factor_requirement def show unless current_user.otp_secret @@ -13,11 +13,24 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController current_user.save! if current_user.changed? if two_factor_authentication_required? && !current_user.two_factor_enabled? - if two_factor_grace_period_expired? - flash.now[:alert] = 'You must enable Two-Factor Authentication for your account.' - else + two_factor_authentication_reason( + global: lambda do + flash.now[:alert] = + 'The global settings require you to enable Two-Factor Authentication for your account.' + end, + group: lambda do |groups| + group_links = groups.map { |group| view_context.link_to group.full_name, group_path(group) }.to_sentence + + flash.now[:alert] = %{ + The group settings for #{group_links} require you to enable + Two-Factor Authentication for your account. + }.html_safe + end + ) + + unless two_factor_grace_period_expired? grace_period_deadline = current_user.otp_grace_period_started_at + two_factor_grace_period.hours - flash.now[:alert] = "You must enable Two-Factor Authentication for your account before #{l(grace_period_deadline)}." + flash.now[:alert] << " You need to do this before #{l(grace_period_deadline)}." end end @@ -71,7 +84,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController if two_factor_grace_period_expired? redirect_to new_profile_two_factor_auth_path, alert: 'Cannot skip two factor authentication setup' else - session[:skip_tfa] = current_user.otp_grace_period_started_at + two_factor_grace_period.hours + session[:skip_two_factor] = current_user.otp_grace_period_started_at + two_factor_grace_period.hours redirect_to root_path end end diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 80a95c6158b..73706bf8dae 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -7,9 +7,11 @@ class Projects::BlobController < Projects::ApplicationController # Raised when given an invalid file path InvalidPathError = Class.new(StandardError) + prepend_before_action :authenticate_user!, only: [:edit] + before_action :require_non_empty_project, except: [:new, :create] before_action :authorize_download_code! - before_action :authorize_edit_tree!, only: [:new, :create, :edit, :update, :destroy] + before_action :authorize_edit_tree!, only: [:new, :create, :update, :destroy] before_action :assign_blob_vars before_action :commit, except: [:new, :create] before_action :blob, except: [:new, :create] @@ -37,7 +39,11 @@ class Projects::BlobController < Projects::ApplicationController end def edit - blob.load_all_data!(@repository) + if can_collaborate_with_project? + blob.load_all_data!(@repository) + else + redirect_to action: 'show' + end end def update diff --git a/app/controllers/projects/builds_controller.rb b/app/controllers/projects/builds_controller.rb index 3f3c90a49ab..add66ce9f84 100644 --- a/app/controllers/projects/builds_controller.rb +++ b/app/controllers/projects/builds_controller.rb @@ -31,25 +31,25 @@ class Projects::BuildsController < Projects::ApplicationController @builds = @project.pipelines.find_by_sha(@build.sha).builds.order('id DESC') @builds = @builds.where("id not in (?)", @build.id) @pipeline = @build.pipeline - - respond_to do |format| - format.html - format.json do - render json: { - id: @build.id, - status: @build.status, - trace_html: @build.trace_html - } - end - end end def trace - respond_to do |format| - format.json do - state = params[:state].presence - render json: @build.trace_with_state(state: state). - merge!(id: @build.id, status: @build.status) + build.trace.read do |stream| + respond_to do |format| + format.json do + result = { + id: @build.id, status: @build.status, complete: @build.complete? + } + + if stream.valid? + stream.limit + state = params[:state].presence + trace = stream.html_with_state(state) + result.merge!(trace.to_h) + end + + render json: result + end end end end @@ -86,10 +86,12 @@ class Projects::BuildsController < Projects::ApplicationController end def raw - if @build.has_trace_file? - send_file @build.trace_file_path, type: 'text/plain; charset=utf-8', disposition: 'inline' - else - render_404 + build.trace.read do |stream| + if stream.file? + send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline' + else + render_404 + end end end diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index cc67f688d51..8362bfc39dd 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -2,6 +2,7 @@ # # Not to be confused with CommitsController, plural. class Projects::CommitController < Projects::ApplicationController + include RendersNotes include CreatesCommit include DiffForPath include DiffHelper @@ -111,22 +112,19 @@ class Projects::CommitController < Projects::ApplicationController end def define_note_vars - @grouped_diff_discussions = commit.notes.grouped_diff_discussions - @notes = commit.notes.non_diff_notes.fresh - - Banzai::NoteRenderer.render( - @grouped_diff_discussions.values.flat_map(&:notes) + @notes, - @project, - current_user, - ) - + @noteable = @commit @note = @project.build_commit_note(commit) - @noteable = @commit - @comments_target = { + @new_diff_note_attrs = { noteable_type: 'Commit', commit_id: @commit.id } + + @grouped_diff_discussions = commit.grouped_diff_discussions + @discussions = commit.discussions + + @notes = (@grouped_diff_discussions.values.flatten + @discussions).flat_map(&:notes) + @notes = prepare_notes_for_rendering(@notes) end def assign_change_commit_vars diff --git a/app/controllers/projects/container_registry_controller.rb b/app/controllers/projects/container_registry_controller.rb deleted file mode 100644 index d1f46497207..00000000000 --- a/app/controllers/projects/container_registry_controller.rb +++ /dev/null @@ -1,34 +0,0 @@ -class Projects::ContainerRegistryController < Projects::ApplicationController - before_action :verify_registry_enabled - before_action :authorize_read_container_image! - before_action :authorize_update_container_image!, only: [:destroy] - layout 'project' - - def index - @tags = container_registry_repository.tags - end - - def destroy - url = namespace_project_container_registry_index_path(project.namespace, project) - - if tag.delete - redirect_to url - else - redirect_to url, alert: 'Failed to remove tag' - end - end - - private - - def verify_registry_enabled - render_404 unless Gitlab.config.registry.enabled - end - - def container_registry_repository - @container_registry_repository ||= project.container_registry_repository - end - - def tag - @tag ||= container_registry_repository.tag(params[:id]) - end -end diff --git a/app/controllers/projects/discussions_controller.rb b/app/controllers/projects/discussions_controller.rb index 1349b015a63..f4a18a5e8f7 100644 --- a/app/controllers/projects/discussions_controller.rb +++ b/app/controllers/projects/discussions_controller.rb @@ -28,7 +28,7 @@ class Projects::DiscussionsController < Projects::ApplicationController end def discussion - @discussion ||= @merge_request.find_diff_discussion(params[:id]) || render_404 + @discussion ||= @merge_request.find_discussion(params[:id]) || render_404 end def authorize_resolve_discussion! diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index a50e16fa4ff..cbf67137261 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -1,5 +1,5 @@ class Projects::IssuesController < Projects::ApplicationController - include NotesHelper + include RendersNotes include ToggleSubscriptionAction include IssuableActions include ToggleAwardEmoji @@ -84,15 +84,11 @@ class Projects::IssuesController < Projects::ApplicationController end def show - raw_notes = @issue.notes.inc_relations_for_view.fresh - - @notes = Banzai::NoteRenderer. - render(raw_notes, @project, current_user, @path, @project_wiki, @ref) - - @note = @project.notes.new(noteable: @issue) @noteable = @issue + @note = @project.notes.new(noteable: @issue) - preload_max_access_for_authors(@notes, @project) + @discussions = @issue.discussions + @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes)) respond_to do |format| format.html diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index a79d801991a..f5bc75d02c9 100755 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -3,7 +3,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController include DiffForPath include DiffHelper include IssuableActions - include NotesHelper + include RendersNotes include ToggleAwardEmoji include IssuableCollections @@ -452,7 +452,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController if pipeline status = pipeline.status - coverage = pipeline.try(:coverage) + coverage = pipeline.coverage status = "success_with_warnings" if pipeline.success? && pipeline.has_warnings? @@ -570,20 +570,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController @note = @project.notes.new(noteable: @merge_request) @discussions = @merge_request.discussions - - preload_noteable_for_regular_notes(@discussions.flat_map(&:notes)) - - # This is not executed lazily - @notes = Banzai::NoteRenderer.render( - @discussions.flat_map(&:notes), - @project, - current_user, - @path, - @project_wiki, - @ref - ) - - preload_max_access_for_authors(@notes, @project) + @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes)) end def define_widget_vars @@ -596,22 +583,15 @@ class Projects::MergeRequestsController < Projects::ApplicationController end def define_diff_comment_vars - @comments_target = { + @new_diff_note_attrs = { noteable_type: 'MergeRequest', noteable_id: @merge_request.id } @use_legacy_diff_notes = !@merge_request.has_complete_diff_refs? - @grouped_diff_discussions = @merge_request.notes.inc_relations_for_view.grouped_diff_discussions - - Banzai::NoteRenderer.render( - @grouped_diff_discussions.values.flat_map(&:notes), - @project, - current_user, - @path, - @project_wiki, - @ref - ) + + @grouped_diff_discussions = @merge_request.grouped_diff_discussions + @notes = prepare_notes_for_rendering(@grouped_diff_discussions.values.flatten.flat_map(&:notes)) end def define_pipelines_vars diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb index d00177e7612..405ea3c0a4f 100644 --- a/app/controllers/projects/notes_controller.rb +++ b/app/controllers/projects/notes_controller.rb @@ -1,4 +1,5 @@ class Projects::NotesController < Projects::ApplicationController + include RendersNotes include ToggleAwardEmoji # Authorize @@ -6,13 +7,15 @@ class Projects::NotesController < Projects::ApplicationController before_action :authorize_create_note!, only: [:create] before_action :authorize_admin_note!, only: [:update, :destroy] before_action :authorize_resolve_note!, only: [:resolve, :unresolve] - before_action :find_current_user_notes, only: [:index] def index current_fetched_at = Time.now.to_i notes_json = { notes: [], last_fetched_at: current_fetched_at } + @notes = notes_finder.execute.inc_relations_for_view + @notes = prepare_notes_for_rendering(@notes) + @notes.each do |note| next if note.cross_reference_not_visible_for?(current_user) @@ -23,7 +26,10 @@ class Projects::NotesController < Projects::ApplicationController end def create - create_params = note_params.merge(merge_request_diff_head_sha: params[:merge_request_diff_head_sha]) + create_params = note_params.merge( + merge_request_diff_head_sha: params[:merge_request_diff_head_sha], + in_reply_to_discussion_id: params[:in_reply_to_discussion_id] + ) @note = Notes::CreateService.new(project, current_user, create_params).execute if @note.is_a?(Note) @@ -111,6 +117,17 @@ class Projects::NotesController < Projects::ApplicationController ) end + def discussion_html(discussion) + return if discussion.individual_note? + + render_to_string( + "discussions/_discussion", + layout: false, + formats: [:html], + locals: { discussion: discussion } + ) + end + def diff_discussion_html(discussion) return unless discussion.diff_discussion? @@ -118,13 +135,13 @@ class Projects::NotesController < Projects::ApplicationController template = "discussions/_parallel_diff_discussion" locals = if params[:line_type] == 'old' - { discussion_left: discussion, discussion_right: nil } + { discussions_left: [discussion], discussions_right: nil } else - { discussion_left: nil, discussion_right: discussion } + { discussions_left: nil, discussions_right: [discussion] } end else template = "discussions/_diff_discussion" - locals = { discussion: discussion } + locals = { discussions: [discussion] } end render_to_string( @@ -135,54 +152,28 @@ class Projects::NotesController < Projects::ApplicationController ) end - def discussion_html(discussion) - return unless discussion.diff_discussion? - - render_to_string( - "discussions/_discussion", - layout: false, - formats: [:html], - locals: { discussion: discussion } - ) - end - def note_json(note) attrs = { - id: note.id + commands_changes: note.commands_changes } if note.persisted? - Banzai::NoteRenderer.render([note], @project, current_user) - attrs.merge!( valid: true, - discussion_id: note.discussion_id, + id: note.id, + discussion_id: note.discussion_id(noteable), html: note_html(note), note: note.note ) - if note.diff_note? - discussion = note.to_discussion - + discussion = note.to_discussion(noteable) + unless discussion.individual_note? attrs.merge!( + discussion_resolvable: discussion.resolvable?, + diff_discussion_html: diff_discussion_html(discussion), discussion_html: discussion_html(discussion) ) - - # The discussion_id is used to add the comment to the correct discussion - # element on the merge request page. Among other things, the discussion_id - # contains the sha of head commit of the merge request. - # When new commits are pushed into the merge request after the initial - # load of the merge request page, the discussion elements will still have - # the old discussion_ids, with the old head commit sha. The new comment, - # however, will have the new discussion_id with the new commit sha. - # To ensure that these new comments will still end up in the correct - # discussion element, we also send the original discussion_id, with the - # old commit sha, along, and fall back on this value when no discussion - # element with the new discussion_id could be found. - if note.new_diff_note? && note.position != note.original_position - attrs[:original_discussion_id] = note.original_discussion_id - end end else attrs.merge!( @@ -191,7 +182,6 @@ class Projects::NotesController < Projects::ApplicationController ) end - attrs[:commands_changes] = note.commands_changes attrs end @@ -205,14 +195,30 @@ class Projects::NotesController < Projects::ApplicationController def note_params params.require(:note).permit( - :note, :noteable, :noteable_id, :noteable_type, :project_id, - :attachment, :line_code, :commit_id, :type, :position + :project_id, + :noteable_type, + :noteable_id, + :commit_id, + :noteable, + :type, + + :note, + :attachment, + + # LegacyDiffNote + :line_code, + + # DiffNote + :position ) end - def find_current_user_notes - @notes = NotesFinder.new(project, current_user, params.merge(last_fetched_at: last_fetched_at)) - .execute.inc_author + def notes_finder + @notes_finder ||= NotesFinder.new(project, current_user, params.merge(last_fetched_at: last_fetched_at)) + end + + def noteable + @noteable ||= notes_finder.target end def last_fetched_at diff --git a/app/controllers/projects/registry/application_controller.rb b/app/controllers/projects/registry/application_controller.rb new file mode 100644 index 00000000000..a56f9c58726 --- /dev/null +++ b/app/controllers/projects/registry/application_controller.rb @@ -0,0 +1,16 @@ +module Projects + module Registry + class ApplicationController < Projects::ApplicationController + layout 'project' + + before_action :verify_registry_enabled! + before_action :authorize_read_container_image! + + private + + def verify_registry_enabled! + render_404 unless Gitlab.config.registry.enabled + end + end + end +end diff --git a/app/controllers/projects/registry/repositories_controller.rb b/app/controllers/projects/registry/repositories_controller.rb new file mode 100644 index 00000000000..17f391ba07f --- /dev/null +++ b/app/controllers/projects/registry/repositories_controller.rb @@ -0,0 +1,43 @@ +module Projects + module Registry + class RepositoriesController < ::Projects::Registry::ApplicationController + before_action :authorize_update_container_image!, only: [:destroy] + before_action :ensure_root_container_repository!, only: [:index] + + def index + @images = project.container_repositories + end + + def destroy + if image.destroy + redirect_to project_container_registry_path(@project), + notice: 'Image repository has been removed successfully!' + else + redirect_to project_container_registry_path(@project), + alert: 'Failed to remove image repository!' + end + end + + private + + def image + @image ||= project.container_repositories.find(params[:id]) + end + + ## + # Container repository object for root project path. + # + # Needed to maintain a backwards compatibility. + # + def ensure_root_container_repository! + ContainerRegistry::Path.new(@project.full_path).tap do |path| + break if path.has_repository? + + ContainerRepository.build_from_path(path).tap do |repository| + repository.save! if repository.has_tags? + end + end + end + end + end +end diff --git a/app/controllers/projects/registry/tags_controller.rb b/app/controllers/projects/registry/tags_controller.rb new file mode 100644 index 00000000000..d689cade3ab --- /dev/null +++ b/app/controllers/projects/registry/tags_controller.rb @@ -0,0 +1,28 @@ +module Projects + module Registry + class TagsController < ::Projects::Registry::ApplicationController + before_action :authorize_update_container_image!, only: [:destroy] + + def destroy + if tag.delete + redirect_to project_container_registry_path(@project), + notice: 'Registry tag has been removed successfully!' + else + redirect_to project_container_registry_path(@project), + alert: 'Failed to remove registry tag!' + end + end + + private + + def image + @image ||= project.container_repositories + .find(params[:repository_id]) + end + + def tag + @tag ||= image.tag(params[:id]) + end + end + end +end diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb index ea1a97b7cf0..5c9e0d4d1a1 100644 --- a/app/controllers/projects/snippets_controller.rb +++ b/app/controllers/projects/snippets_controller.rb @@ -1,4 +1,5 @@ class Projects::SnippetsController < Projects::ApplicationController + include RendersNotes include ToggleAwardEmoji include SpammableActions include SnippetsActions @@ -55,8 +56,10 @@ class Projects::SnippetsController < Projects::ApplicationController def show @note = @project.notes.new(noteable: @snippet) - @notes = Banzai::NoteRenderer.render(@snippet.notes.fresh, @project, current_user) @noteable = @snippet + + @discussions = @snippet.discussions + @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes)) end def destroy diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index d8561871098..d3091a4f8e9 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -3,7 +3,7 @@ class SessionsController < Devise::SessionsController include Devise::Controllers::Rememberable include Recaptcha::ClientHelper - skip_before_action :check_2fa_requirement, only: [:destroy] + skip_before_action :check_two_factor_requirement, only: [:destroy] prepend_before_action :check_initial_setup, only: [:new] prepend_before_action :authenticate_with_two_factor, diff --git a/app/finders/notes_finder.rb b/app/finders/notes_finder.rb index 6630c6384f2..3c499184b41 100644 --- a/app/finders/notes_finder.rb +++ b/app/finders/notes_finder.rb @@ -17,29 +17,46 @@ class NotesFinder @project = project @current_user = current_user @params = params - init_collection end def execute - @notes = since_fetch_at(@params[:last_fetched_at]) if @params[:last_fetched_at] - @notes + notes = init_collection + notes = since_fetch_at(notes) + notes.fresh end - private + def target + return @target if defined?(@target) - def init_collection - @notes = - if @params[:target_id] - on_target(@params[:target_type], @params[:target_id]) + target_type = @params[:target_type] + target_id = @params[:target_id] + + return @target = nil unless target_type && target_id + + @target = + if target_type == "commit" + if Ability.allowed?(@current_user, :download_code, @project) + @project.commit(target_id) + end else - notes_of_any_type + noteables_for_type(target_type).find(target_id) end end + private + + def init_collection + if target + notes_on_target + else + notes_of_any_type + end + end + def notes_of_any_type types = %w(commit issue merge_request snippet) note_relations = types.map { |t| notes_for_type(t) } - note_relations.map!{ |notes| search(@params[:search], notes) } if @params[:search] + note_relations.map! { |notes| search(notes) } UnionFinder.new.find_union(note_relations, Note) end @@ -69,17 +86,11 @@ class NotesFinder end end - def on_target(target_type, target_id) - if target_type == "commit" - notes_for_type('commit').for_commit_id(target_id) + def notes_on_target + if target.respond_to?(:related_notes) + target.related_notes else - target = noteables_for_type(target_type).find(target_id) - - if target.respond_to?(:related_notes) - target.related_notes - else - target.notes - end + target.notes end end @@ -87,17 +98,21 @@ class NotesFinder # # This method uses ILIKE on PostgreSQL and LIKE on MySQL. # - def search(query, notes_relation = @notes) + def search(notes) + query = @params[:search] + return notes unless query + pattern = "%#{query}%" - notes_relation.where(Note.arel_table[:note].matches(pattern)) + notes.where(Note.arel_table[:note].matches(pattern)) end # Notes changed since last fetch # Uses overlapping intervals to avoid worrying about race conditions - def since_fetch_at(fetch_time) + def since_fetch_at(notes) + return notes unless @params[:last_fetched_at] + # Default to 0 to remain compatible with old clients last_fetched_at = Time.at(@params.fetch(:last_fetched_at, 0).to_i) - - @notes.where('updated_at > ?', last_fetched_at - FETCH_OVERLAP).fresh + notes.updated_after(last_fetched_at - FETCH_OVERLAP) end end diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index 101fe579da2..9c71d6c7f4c 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -64,18 +64,6 @@ module AuthHelper current_user.identities.exists?(provider: provider.to_s) end - def two_factor_skippable? - current_application_settings.require_two_factor_authentication && - !current_user.two_factor_enabled? && - current_application_settings.two_factor_grace_period && - !two_factor_grace_period_expired? - end - - def two_factor_grace_period_expired? - current_user.otp_grace_period_started_at && - (current_user.otp_grace_period_started_at + current_application_settings.two_factor_grace_period.hours) < Time.current - end - def unlink_allowed?(provider) %w(saml cas3).exclude?(provider.to_s) end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 8631bc54509..91d6d1852cf 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -8,31 +8,36 @@ module BlobHelper %w(credits changelog news copying copyright license authors) end - def edit_blob_link(project = @project, ref = @ref, path = @path, options = {}) - return unless current_user + def edit_path(project = @project, ref = @ref, path = @path, options = {}) + namespace_project_edit_blob_path(project.namespace, project, + tree_join(ref, path), + options[:link_opts]) + end + def fork_path(project = @project, ref = @ref, path = @path, options = {}) + continue_params = { + to: edit_path, + notice: edit_in_new_fork_notice, + notice_now: edit_in_new_fork_notice_now + } + namespace_project_forks_path(project.namespace, project, namespace_key: current_user.namespace.id, continue: continue_params) + end + + def edit_blob_link(project = @project, ref = @ref, path = @path, options = {}) blob = options.delete(:blob) blob ||= project.repository.blob_at(ref, path) rescue nil return unless blob - edit_path = namespace_project_edit_blob_path(project.namespace, project, - tree_join(ref, path), - options[:link_opts]) + common_classes = "btn js-edit-blob #{options[:extra_class]}" if !on_top_of_branch?(project, ref) - button_tag "Edit", class: "btn disabled has-tooltip", title: "You can only edit files when you are on a branch", data: { container: 'body' } - elsif can_edit_blob?(blob, project, ref) - link_to "Edit", edit_path, class: 'btn btn-sm' - elsif can?(current_user, :fork_project, project) - continue_params = { - to: edit_path, - notice: edit_in_new_fork_notice, - notice_now: edit_in_new_fork_notice_now - } - fork_path = namespace_project_forks_path(project.namespace, project, namespace_key: current_user.namespace.id, continue: continue_params) - - link_to "Edit", fork_path, class: 'btn', method: :post + button_tag 'Edit', class: "#{common_classes} disabled has-tooltip", title: "You can only edit files when you are on a branch", data: { container: 'body' } + # This condition applies to anonymous or users who can edit directly + elsif !current_user || (current_user && can_edit_blob?(blob, project, ref)) + link_to 'Edit', edit_path(project, ref, path, options), class: "#{common_classes} btn-sm" + elsif current_user && can?(current_user, :fork_project, project) + button_tag 'Edit', class: "#{common_classes} js-edit-blob-link-fork-toggler" end end diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index aed1d7c839f..5e0886cc599 100644 --- a/app/helpers/diff_helper.rb +++ b/app/helpers/diff_helper.rb @@ -62,19 +62,19 @@ module DiffHelper end def parallel_diff_discussions(left, right, diff_file) - discussion_left = discussion_right = nil + discussions_left = discussions_right = nil if left && (left.unchanged? || left.removed?) line_code = diff_file.line_code(left) - discussion_left = @grouped_diff_discussions[line_code] + discussions_left = @grouped_diff_discussions[line_code] end if right && right.added? line_code = diff_file.line_code(right) - discussion_right = @grouped_diff_discussions[line_code] + discussions_right = @grouped_diff_discussions[line_code] end - [discussion_left, discussion_right] + [discussions_left, discussions_right] end def inline_diff_btn diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb index 81e0b6bb5ae..8ed99642c7a 100644 --- a/app/helpers/dropdowns_helper.rb +++ b/app/helpers/dropdowns_helper.rb @@ -1,6 +1,6 @@ module DropdownsHelper def dropdown_tag(toggle_text, options: {}, &block) - content_tag :div, class: "dropdown" do + content_tag :div, class: "dropdown #{options[:wrapper_class] if options.has_key?(:wrapper_class)}" do data_attr = { toggle: "dropdown" } if options.has_key?(:data) @@ -20,7 +20,7 @@ module DropdownsHelper output << dropdown_filter(options[:placeholder]) end - output << content_tag(:div, class: "dropdown-content") do + output << content_tag(:div, class: "dropdown-content #{options[:content_class] if options.has_key?(:content_class)}") do capture(&block) if block && !options.has_key?(:footer_content) end diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index b0331f36a2f..5f3d89cf6cb 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -24,57 +24,24 @@ module NotesHelper end def diff_view_data - return {} unless @comments_target + return {} unless @new_diff_note_attrs - @comments_target.slice(:noteable_id, :noteable_type, :commit_id) + @new_diff_note_attrs.slice(:noteable_id, :noteable_type, :commit_id) end def diff_view_line_data(line_code, position, line_type) return if @diff_notes_disabled - use_legacy_diff_note = @use_legacy_diff_notes - # If the controller doesn't force the use of legacy diff notes, we - # determine this on a line-by-line basis by seeing if there already exist - # active legacy diff notes at this line, in which case newly created notes - # will use the legacy technology as well. - # We do this because the discussion_id values of legacy and "new" diff - # notes, which are used to group notes on the merge request discussion tab, - # are incompatible. - # If we didn't, diff notes that would show for the same line on the changes - # tab, would show in different discussions on the discussion tab. - use_legacy_diff_note ||= begin - discussion = @grouped_diff_discussions[line_code] - discussion && discussion.legacy_diff_discussion? - end - data = { line_code: line_code, line_type: line_type, } - if use_legacy_diff_note - discussion_id = LegacyDiffNote.discussion_id( - @comments_target[:noteable_type], - @comments_target[:noteable_id] || @comments_target[:commit_id], - line_code - ) - - data.merge!( - note_type: LegacyDiffNote.name, - discussion_id: discussion_id - ) + if @use_legacy_diff_notes + data[:note_type] = LegacyDiffNote.name else - discussion_id = DiffNote.discussion_id( - @comments_target[:noteable_type], - @comments_target[:noteable_id] || @comments_target[:commit_id], - position - ) - - data.merge!( - position: position.to_json, - note_type: DiffNote.name, - discussion_id: discussion_id - ) + data[:note_type] = DiffNote.name + data[:position] = position.to_json end data @@ -83,21 +50,12 @@ module NotesHelper def link_to_reply_discussion(discussion, line_type = nil) return unless current_user - data = discussion.reply_attributes.merge(line_type: line_type) + data = { discussion_id: discussion.id, line_type: line_type } button_tag 'Reply...', class: 'btn btn-text-field js-discussion-reply-button', data: data, title: 'Add a reply' end - def preload_max_access_for_authors(notes, project) - user_ids = notes.map(&:author_id) - project.team.max_member_access_for_user_ids(user_ids) - end - - def preload_noteable_for_regular_notes(notes) - ActiveRecord::Associations::Preloader.new.preload(notes.select { |note| !note.for_commit? }, :noteable) - end - def note_max_access_for_user(note) note.project.team.human_max_access(note.author_id) end diff --git a/app/mailers/emails/notes.rb b/app/mailers/emails/notes.rb index 46fa6fd9f6d..00707a0023e 100644 --- a/app/mailers/emails/notes.rb +++ b/app/mailers/emails/notes.rb @@ -4,13 +4,8 @@ module Emails setup_note_mail(note_id, recipient_id) @commit = @note.noteable - @discussion = @note.to_discussion if @note.diff_note? @target_url = namespace_project_commit_url(*note_target_url_options) - - mail_answer_thread(@commit, - from: sender(@note.author_id), - to: recipient(recipient_id), - subject: subject("#{@commit.title} (#{@commit.short_id})")) + mail_answer_thread(@commit, note_thread_options(recipient_id)) end def note_issue_email(recipient_id, note_id) @@ -25,7 +20,6 @@ module Emails setup_note_mail(note_id, recipient_id) @merge_request = @note.noteable - @discussion = @note.to_discussion if @note.diff_note? @target_url = namespace_project_merge_request_url(*note_target_url_options) mail_answer_thread(@merge_request, note_thread_options(recipient_id)) end @@ -56,15 +50,18 @@ module Emails { from: sender(@note.author_id), to: recipient(recipient_id), - subject: subject("#{@note.noteable.title} (#{@note.noteable.to_reference})") + subject: subject("#{@note.noteable.title} (#{@note.noteable.reference_link_text})") } end def setup_note_mail(note_id, recipient_id) - @note = Note.find(note_id) + # `note_id` is a `Note` when originating in `NotifyPreview` + @note = note_id.is_a?(Note) ? note_id : Note.find(note_id) @project = @note.project - @sent_notification = SentNotification.record_note(@note, recipient_id, reply_key) + if @project && @note.persisted? + @sent_notification = SentNotification.record_note(@note, recipient_id, reply_key) + end end end end diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index 14df6f8f0a3..f315e38bcaa 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -111,7 +111,7 @@ class Notify < BaseMailer headers["X-GitLab-#{model.class.name}-ID"] = model.id headers['X-GitLab-Reply-Key'] = reply_key - if Gitlab::IncomingEmail.enabled? + if Gitlab::IncomingEmail.enabled? && @sent_notification address = Mail::Address.new(Gitlab::IncomingEmail.reply_address(reply_key)) address.display_name = @project.name_with_namespace @@ -176,6 +176,6 @@ class Notify < BaseMailer end headers['List-Unsubscribe'] = list_unsubscribe_methods.map { |e| "<#{e}>" }.join(',') - @sent_notification_url = unsubscribe_sent_notification_url(@sent_notification) + @unsubscribe_url = unsubscribe_sent_notification_url(@sent_notification) end end diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb index 6937ad3bdd9..6ada6fae4eb 100644 --- a/app/models/award_emoji.rb +++ b/app/models/award_emoji.rb @@ -3,13 +3,14 @@ class AwardEmoji < ActiveRecord::Base UPVOTE_NAME = "thumbsup".freeze include Participable + include GhostUser belongs_to :awardable, polymorphic: true belongs_to :user validates :awardable, :user, presence: true validates :name, presence: true, inclusion: { in: Gitlab::Emoji.emojis_names } - validates :name, uniqueness: { scope: [:user, :awardable_type, :awardable_id] } + validates :name, uniqueness: { scope: [:user, :awardable_type, :awardable_id] }, unless: :ghost_user? participant :user diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 8431c5f228c..70470414bc4 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -171,19 +171,6 @@ module Ci latest_builds.where('stage_idx < ?', stage_idx) end - def trace_html(**args) - trace_with_state(**args)[:html] || '' - end - - def trace_with_state(state: nil, last_lines: nil) - trace_ansi = trace(last_lines: last_lines) - if trace_ansi.present? - Ci::Ansi2html.convert(trace_ansi, state) - else - {} - end - end - def timeout project.build_timeout end @@ -244,136 +231,35 @@ module Ci end def update_coverage - coverage = extract_coverage(trace, coverage_regex) + coverage = trace.extract_coverage(coverage_regex) update_attributes(coverage: coverage) if coverage.present? end - def extract_coverage(text, regex) - return unless regex - - matches = text.scan(Regexp.new(regex)).last - matches = matches.last if matches.is_a?(Array) - coverage = matches.gsub(/\d+(\.\d+)?/).first - - if coverage.present? - coverage.to_f - end - rescue - # if bad regex or something goes wrong we dont want to interrupt transition - # so we just silentrly ignore error for now - end - - def has_trace_file? - File.exist?(path_to_trace) || has_old_trace_file? + def trace + Gitlab::Ci::Trace.new(self) end def has_trace? - raw_trace.present? + trace.exist? end - def raw_trace(last_lines: nil) - if File.exist?(trace_file_path) - Gitlab::Ci::TraceReader.new(trace_file_path). - read(last_lines: last_lines) - else - # backward compatibility - read_attribute :trace - end + def trace=(data) + raise NotImplementedError end - ## - # Deprecated - # - # This is a hotfix for CI build data integrity, see #4246 - def has_old_trace_file? - project.ci_id && File.exist?(old_path_to_trace) - end - - def trace(last_lines: nil) - hide_secrets(raw_trace(last_lines: last_lines)) + def old_trace + read_attribute(:trace) end - def trace_length - if raw_trace - raw_trace.bytesize - else - 0 - end - end - - def trace=(trace) - recreate_trace_dir - trace = hide_secrets(trace) - File.write(path_to_trace, trace) - end - - def recreate_trace_dir - unless Dir.exist?(dir_to_trace) - FileUtils.mkdir_p(dir_to_trace) - end - end - private :recreate_trace_dir - - def append_trace(trace_part, offset) - recreate_trace_dir - touch if needs_touch? - - trace_part = hide_secrets(trace_part) - - File.truncate(path_to_trace, offset) if File.exist?(path_to_trace) - File.open(path_to_trace, 'ab') do |f| - f.write(trace_part) - end + def erase_old_trace! + write_attribute(:trace, nil) + save end def needs_touch? Time.now - updated_at > 15.minutes.to_i end - def trace_file_path - if has_old_trace_file? - old_path_to_trace - else - path_to_trace - end - end - - def dir_to_trace - File.join( - Settings.gitlab_ci.builds_path, - created_at.utc.strftime("%Y_%m"), - project.id.to_s - ) - end - - def path_to_trace - "#{dir_to_trace}/#{id}.log" - end - - ## - # Deprecated - # - # This is a hotfix for CI build data integrity, see #4246 - # Should be removed in 8.4, after CI files migration has been done. - # - def old_dir_to_trace - File.join( - Settings.gitlab_ci.builds_path, - created_at.utc.strftime("%Y_%m"), - project.ci_id.to_s - ) - end - - ## - # Deprecated - # - # This is a hotfix for CI build data integrity, see #4246 - # Should be removed in 8.4, after CI files migration has been done. - # - def old_path_to_trace - "#{old_dir_to_trace}/#{id}.log" - end - ## # Deprecated # @@ -555,6 +441,15 @@ module Ci options[:dependencies]&.empty? end + def hide_secrets(trace) + return unless trace + + trace = trace.dup + Ci::MaskSecret.mask!(trace, project.runners_token) if project + Ci::MaskSecret.mask!(trace, token) + trace + end + private def update_artifacts_size @@ -566,7 +461,7 @@ module Ci end def erase_trace! - self.trace = nil + trace.erase! end def update_erased!(user = nil) @@ -628,15 +523,6 @@ module Ci pipeline.config_processor.build_attributes(name) end - def hide_secrets(trace) - return unless trace - - trace = trace.dup - Ci::MaskSecret.mask!(trace, project.runners_token) if project - Ci::MaskSecret.mask!(trace, token) - trace - end - def update_project_statistics return unless project diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb index cba1d81a861..0a89f3e0640 100644 --- a/app/models/ci/trigger.rb +++ b/app/models/ci/trigger.rb @@ -8,6 +8,7 @@ module Ci belongs_to :owner, class_name: "User" has_many :trigger_requests, dependent: :destroy + has_one :trigger_schedule, dependent: :destroy validates :token, presence: true, uniqueness: true diff --git a/app/models/ci/trigger_schedule.rb b/app/models/ci/trigger_schedule.rb new file mode 100644 index 00000000000..10ea381ee31 --- /dev/null +++ b/app/models/ci/trigger_schedule.rb @@ -0,0 +1,30 @@ +module Ci + class TriggerSchedule < ActiveRecord::Base + extend Ci::Model + include Importable + + acts_as_paranoid + + belongs_to :project + belongs_to :trigger + + delegate :ref, to: :trigger + + validates :trigger, presence: { unless: :importing? } + validates :cron, cron: true, presence: { unless: :importing? } + validates :cron_timezone, cron_timezone: true, presence: { unless: :importing? } + validates :ref, presence: { unless: :importing? } + + before_save :set_next_run_at + + def set_next_run_at + self.next_run_at = Gitlab::Ci::CronParser.new(cron, cron_timezone).next_time_from(Time.now) + end + + def schedule_next_run! + save! # with set_next_run_at + rescue ActiveRecord::RecordInvalid + update_attribute(:next_run_at, nil) # update without validation + end + end +end diff --git a/app/models/commit.rb b/app/models/commit.rb index ce92cc369ad..5c452f78546 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -2,6 +2,7 @@ class Commit extend ActiveModel::Naming include ActiveModel::Conversion + include Noteable include Participable include Mentionable include Referable @@ -203,6 +204,10 @@ class Commit project.notes.for_commit_id(self.id) end + def discussion_notes + notes.non_diff_notes + end + def notes_with_associations notes.includes(:author) end diff --git a/app/models/concerns/discussion_on_diff.rb b/app/models/concerns/discussion_on_diff.rb new file mode 100644 index 00000000000..87db0c810c3 --- /dev/null +++ b/app/models/concerns/discussion_on_diff.rb @@ -0,0 +1,57 @@ +# Contains functionality shared between `DiffDiscussion` and `LegacyDiffDiscussion`. +module DiscussionOnDiff + extend ActiveSupport::Concern + + included do + NUMBER_OF_TRUNCATED_DIFF_LINES = 16 + + memoized_values << :active + + delegate :line_code, + :original_line_code, + :diff_file, + :diff_line, + :for_line?, + :active?, + + to: :first_note + + delegate :file_path, + :blob, + :highlighted_diff_lines, + :diff_lines, + + to: :diff_file, + allow_nil: true + end + + def diff_discussion? + true + end + + def active? + return @active if @active.present? + + @active = first_note.active? + end + + # Returns an array of at most 16 highlighted lines above a diff note + def truncated_diff_lines(highlight: true) + lines = highlight ? highlighted_diff_lines : diff_lines + prev_lines = [] + + lines.each do |line| + if line.meta? + prev_lines.clear + else + prev_lines << line + + break if for_line?(line) + + prev_lines.shift if prev_lines.length >= NUMBER_OF_TRUNCATED_DIFF_LINES + end + end + + prev_lines + end +end diff --git a/app/models/concerns/ghost_user.rb b/app/models/concerns/ghost_user.rb new file mode 100644 index 00000000000..da696127a80 --- /dev/null +++ b/app/models/concerns/ghost_user.rb @@ -0,0 +1,7 @@ +module GhostUser + extend ActiveSupport::Concern + + def ghost_user? + user && user.ghost? + end +end diff --git a/app/models/concerns/ignorable_column.rb b/app/models/concerns/ignorable_column.rb new file mode 100644 index 00000000000..eb9f3423e48 --- /dev/null +++ b/app/models/concerns/ignorable_column.rb @@ -0,0 +1,28 @@ +# Module that can be included into a model to make it easier to ignore database +# columns. +# +# Example: +# +# class User < ActiveRecord::Base +# include IgnorableColumn +# +# ignore_column :updated_at +# end +# +module IgnorableColumn + extend ActiveSupport::Concern + + module ClassMethods + def columns + super.reject { |column| ignored_columns.include?(column.name) } + end + + def ignored_columns + @ignored_columns ||= Set.new + end + + def ignore_column(name) + ignored_columns << name.to_s + end + end +end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index b4dded7e27e..3d2258d5e3e 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -292,17 +292,6 @@ module Issuable self.class.to_ability_name end - # Convert this Issuable class name to a format usable by notifications. - # - # Examples: - # - # issuable.class # => MergeRequest - # issuable.human_class_name # => "merge request" - - def human_class_name - @human_class_name ||= self.class.name.titleize.downcase - end - # Returns a Hash of attributes to be used for Twitter card metadata def card_attributes { diff --git a/app/models/concerns/note_on_diff.rb b/app/models/concerns/note_on_diff.rb index b8dd27a7afe..1a5a7007a2b 100644 --- a/app/models/concerns/note_on_diff.rb +++ b/app/models/concerns/note_on_diff.rb @@ -1,3 +1,4 @@ +# Contains functionality shared between `DiffNote` and `LegacyDiffNote`. module NoteOnDiff extend ActiveSupport::Concern @@ -24,12 +25,4 @@ module NoteOnDiff def diff_attributes raise NotImplementedError end - - def can_be_award_emoji? - false - end - - def to_discussion - Discussion.new([self]) - end end diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb new file mode 100644 index 00000000000..772ff6a6d2f --- /dev/null +++ b/app/models/concerns/noteable.rb @@ -0,0 +1,68 @@ +module Noteable + # Names of all implementers of `Noteable` that support resolvable notes. + RESOLVABLE_TYPES = %w(MergeRequest).freeze + + def base_class_name + self.class.base_class.name + end + + # Convert this Noteable class name to a format usable by notifications. + # + # Examples: + # + # noteable.class # => MergeRequest + # noteable.human_class_name # => "merge request" + def human_class_name + @human_class_name ||= base_class_name.titleize.downcase + end + + def supports_resolvable_notes? + RESOLVABLE_TYPES.include?(base_class_name) + end + + def supports_discussions? + DiscussionNote::NOTEABLE_TYPES.include?(base_class_name) + end + + def discussion_notes + notes + end + + delegate :find_discussion, to: :discussion_notes + + def discussions + @discussions ||= discussion_notes + .inc_relations_for_view + .discussions(self) + end + + def grouped_diff_discussions + # Doesn't use `discussion_notes`, because this may include commit diff notes + # besides MR diff notes, that we do no want to display on the MR Changes tab. + notes.inc_relations_for_view.grouped_diff_discussions + end + + def resolvable_discussions + @resolvable_discussions ||= discussion_notes.resolvable.discussions(self) + end + + def discussions_resolvable? + resolvable_discussions.any?(&:resolvable?) + end + + def discussions_resolved? + discussions_resolvable? && resolvable_discussions.none?(&:to_be_resolved?) + end + + def discussions_to_be_resolved? + discussions_resolvable? && !discussions_resolved? + end + + def discussions_to_be_resolved + @discussions_to_be_resolved ||= resolvable_discussions.select(&:to_be_resolved?) + end + + def discussions_can_be_resolved_by?(user) + discussions_to_be_resolved.all? { |discussion| discussion.can_resolve?(user) } + end +end diff --git a/app/models/concerns/resolvable_discussion.rb b/app/models/concerns/resolvable_discussion.rb new file mode 100644 index 00000000000..dd979e7bb17 --- /dev/null +++ b/app/models/concerns/resolvable_discussion.rb @@ -0,0 +1,103 @@ +module ResolvableDiscussion + extend ActiveSupport::Concern + + included do + # A number of properties of this `Discussion`, like `first_note` and `resolvable?`, are memoized. + # When this discussion is resolved or unresolved, the values of these properties potentially change. + # To make sure all memoized values are reset when this happens, `update` resets all instance variables with names in + # `memoized_variables`. If you add a memoized method in `ResolvableDiscussion` or any `Discussion` subclass, + # please make sure the instance variable name is added to `memoized_values`, like below. + cattr_accessor :memoized_values, instance_accessor: false do + [] + end + + memoized_values.push( + :resolvable, + :resolved, + :first_note, + :first_note_to_resolve, + :last_resolved_note, + :last_note + ) + + delegate :potentially_resolvable?, to: :first_note + + delegate :resolved_at, + :resolved_by, + + to: :last_resolved_note, + allow_nil: true + end + + def resolvable? + return @resolvable if @resolvable.present? + + @resolvable = potentially_resolvable? && notes.any?(&:resolvable?) + end + + def resolved? + return @resolved if @resolved.present? + + @resolved = resolvable? && notes.none?(&:to_be_resolved?) + end + + def first_note + @first_note ||= notes.first + end + + def first_note_to_resolve + return unless resolvable? + + @first_note_to_resolve ||= notes.find(&:to_be_resolved?) + end + + def last_resolved_note + return unless resolved? + + @last_resolved_note ||= resolved_notes.sort_by(&:resolved_at).last + end + + def resolved_notes + notes.select(&:resolved?) + end + + def to_be_resolved? + resolvable? && !resolved? + end + + def can_resolve?(current_user) + return false unless current_user + return false unless resolvable? + + current_user == self.noteable.author || + current_user.can?(:resolve_note, self.project) + end + + def resolve!(current_user) + return unless resolvable? + + update { |notes| notes.resolve!(current_user) } + end + + def unresolve! + return unless resolvable? + + update { |notes| notes.unresolve! } + end + + private + + def update + # Do not select `Note.resolvable`, so that system notes remain in the collection + notes_relation = Note.where(id: notes.map(&:id)) + + yield(notes_relation) + + # Set the notes array to the updated notes + @notes = notes_relation.fresh.to_a + + self.class.memoized_values.each do |var| + instance_variable_set(:"@#{var}", nil) + end + end +end diff --git a/app/models/concerns/resolvable_note.rb b/app/models/concerns/resolvable_note.rb new file mode 100644 index 00000000000..05eb6f86704 --- /dev/null +++ b/app/models/concerns/resolvable_note.rb @@ -0,0 +1,72 @@ +module ResolvableNote + extend ActiveSupport::Concern + + # Names of all subclasses of `Note` that can be resolvable. + RESOLVABLE_TYPES = %w(DiffNote DiscussionNote).freeze + + included do + belongs_to :resolved_by, class_name: "User" + + validates :resolved_by, presence: true, if: :resolved? + + # Keep this scope in sync with `#potentially_resolvable?` + scope :potentially_resolvable, -> { where(type: RESOLVABLE_TYPES).where(noteable_type: Noteable::RESOLVABLE_TYPES) } + # Keep this scope in sync with `#resolvable?` + scope :resolvable, -> { potentially_resolvable.user } + + scope :resolved, -> { resolvable.where.not(resolved_at: nil) } + scope :unresolved, -> { resolvable.where(resolved_at: nil) } + end + + module ClassMethods + # This method must be kept in sync with `#resolve!` + def resolve!(current_user) + unresolved.update_all(resolved_at: Time.now, resolved_by_id: current_user.id) + end + + # This method must be kept in sync with `#unresolve!` + def unresolve! + resolved.update_all(resolved_at: nil, resolved_by_id: nil) + end + end + + # Keep this method in sync with the `potentially_resolvable` scope + def potentially_resolvable? + RESOLVABLE_TYPES.include?(self.class.name) && noteable.supports_resolvable_notes? + end + + # Keep this method in sync with the `resolvable` scope + def resolvable? + potentially_resolvable? && !system? + end + + def resolved? + return false unless resolvable? + + self.resolved_at.present? + end + + def to_be_resolved? + resolvable? && !resolved? + end + + # If you update this method remember to also update `.resolve!` + def resolve!(current_user) + return unless resolvable? + return if resolved? + + self.resolved_at = Time.now + self.resolved_by = current_user + save! + end + + # If you update this method remember to also update `.unresolve!` + def unresolve! + return unless resolvable? + return unless resolved? + + self.resolved_at = nil + self.resolved_by = nil + save! + end +end diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb index 529fb5ce988..aca99feee53 100644 --- a/app/models/concerns/routable.rb +++ b/app/models/concerns/routable.rb @@ -83,6 +83,74 @@ module Routable AND members.source_type = r2.source_type"). where('members.user_id = ?', user_id) end + + # Builds a relation to find multiple objects that are nested under user + # membership. Includes the parent, as opposed to `#member_descendants` + # which only includes the descendants. + # + # Usage: + # + # Klass.member_self_and_descendants(1) + # + # Returns an ActiveRecord::Relation. + def member_self_and_descendants(user_id) + joins(:route). + joins("INNER JOIN routes r2 ON routes.path LIKE CONCAT(r2.path, '/%') + OR routes.path = r2.path + INNER JOIN members ON members.source_id = r2.source_id + AND members.source_type = r2.source_type"). + where('members.user_id = ?', user_id) + end + + # Returns all objects in a hierarchy, where any node in the hierarchy is + # under the user membership. + # + # Usage: + # + # Klass.member_hierarchy(1) + # + # Examples: + # + # Given the following group tree... + # + # _______group_1_______ + # | | + # | | + # nested_group_1 nested_group_2 + # | | + # | | + # nested_group_1_1 nested_group_2_1 + # + # + # ... the following results are returned: + # + # * the user is a member of group 1 + # => 'group_1', + # 'nested_group_1', nested_group_1_1', + # 'nested_group_2', 'nested_group_2_1' + # + # * the user is a member of nested_group_2 + # => 'group1', + # 'nested_group_2', 'nested_group_2_1' + # + # * the user is a member of nested_group_2_1 + # => 'group1', + # 'nested_group_2', 'nested_group_2_1' + # + # Returns an ActiveRecord::Relation. + def member_hierarchy(user_id) + paths = member_self_and_descendants(user_id).pluck('routes.path') + + return none if paths.empty? + + wheres = paths.map do |path| + "#{connection.quote(path)} = routes.path + OR + #{connection.quote(path)} LIKE CONCAT(routes.path, '/%')" + end + + joins(:route).where(wheres.join(' OR ')) + end end def full_name diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb new file mode 100644 index 00000000000..9682df3a586 --- /dev/null +++ b/app/models/container_repository.rb @@ -0,0 +1,77 @@ +class ContainerRepository < ActiveRecord::Base + belongs_to :project + + validates :name, length: { minimum: 0, allow_nil: false } + validates :name, uniqueness: { scope: :project_id } + + delegate :client, to: :registry + + before_destroy :delete_tags! + + def registry + @registry ||= begin + token = Auth::ContainerRegistryAuthenticationService.full_access_token(path) + + url = Gitlab.config.registry.api_url + host_port = Gitlab.config.registry.host_port + + ContainerRegistry::Registry.new(url, token: token, path: host_port) + end + end + + def path + @path ||= [project.full_path, name].select(&:present?).join('/') + end + + def tag(tag) + ContainerRegistry::Tag.new(self, tag) + end + + def manifest + @manifest ||= client.repository_tags(path) + end + + def tags + return @tags if defined?(@tags) + return [] unless manifest && manifest['tags'] + + @tags = manifest['tags'].map do |tag| + ContainerRegistry::Tag.new(self, tag) + end + end + + def blob(config) + ContainerRegistry::Blob.new(self, config) + end + + def has_tags? + tags.any? + end + + def root_repository? + name.empty? + end + + def delete_tags! + return unless has_tags? + + digests = tags.map { |tag| tag.digest }.to_set + + digests.all? do |digest| + client.delete_repository_tag(self.path, digest) + end + end + + def self.build_from_path(path) + self.new(project: path.repository_project, + name: path.repository_name) + end + + def self.create_from_path!(path) + build_from_path(path).tap(&:save!) + end + + def self.build_root_repository(project) + self.new(project: project, name: '') + end +end diff --git a/app/models/diff_discussion.rb b/app/models/diff_discussion.rb new file mode 100644 index 00000000000..d9b7e484e0f --- /dev/null +++ b/app/models/diff_discussion.rb @@ -0,0 +1,26 @@ +# A discussion on merge request or commit diffs consisting of `DiffNote` notes. +# +# A discussion of this type can be resolvable. +class DiffDiscussion < Discussion + include DiscussionOnDiff + + def self.note_class + DiffNote + end + + delegate :position, + :original_position, + + to: :first_note + + def legacy_diff_discussion? + false + end + + def reply_attributes + super.merge( + original_position: original_position.to_json, + position: position.to_json, + ) + end +end diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index 895a91139c9..1523244f8a8 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -1,6 +1,11 @@ +# A note on merge request or commit diffs +# +# A note of this type can be resolvable. class DiffNote < Note include NoteOnDiff + NOTEABLE_TYPES = %w(MergeRequest Commit).freeze + serialize :original_position, Gitlab::Diff::Position serialize :position, Gitlab::Diff::Position @@ -8,59 +13,31 @@ class DiffNote < Note validates :position, presence: true validates :diff_line, presence: true validates :line_code, presence: true, line_code: true - validates :noteable_type, inclusion: { in: %w(Commit MergeRequest) } - validates :resolved_by, presence: true, if: :resolved? + validates :noteable_type, inclusion: { in: NOTEABLE_TYPES } validate :positions_complete validate :verify_supported - # Keep this scope in sync with the logic in `#resolvable?` - scope :resolvable, -> { user.where(noteable_type: 'MergeRequest') } - scope :resolved, -> { resolvable.where.not(resolved_at: nil) } - scope :unresolved, -> { resolvable.where(resolved_at: nil) } - - after_initialize :ensure_original_discussion_id before_validation :set_original_position, :update_position, on: :create - before_validation :set_line_code, :set_original_discussion_id - # We need to do this again, because it's already in `Note`, but is affected by - # `update_position` and needs to run after that. - before_validation :set_discussion_id + before_validation :set_line_code after_save :keep_around_commits - class << self - def build_discussion_id(noteable_type, noteable_id, position) - [super(noteable_type, noteable_id), *position.key].join("-") - end - - # This method must be kept in sync with `#resolve!` - def resolve!(current_user) - unresolved.update_all(resolved_at: Time.now, resolved_by_id: current_user.id) - end - - # This method must be kept in sync with `#unresolve!` - def unresolve! - resolved.update_all(resolved_at: nil, resolved_by_id: nil) - end - end - - def new_diff_note? - true + def discussion_class(*) + DiffDiscussion end - def diff_attributes - { position: position.to_json } - end + %i(original_position position).each do |meth| + define_method "#{meth}=" do |new_position| + if new_position.is_a?(String) + new_position = JSON.parse(new_position) rescue nil + end - def position=(new_position) - if new_position.is_a?(String) - new_position = JSON.parse(new_position) rescue nil - end + if new_position.is_a?(Hash) + new_position = new_position.with_indifferent_access + new_position = Gitlab::Diff::Position.new(new_position) + end - if new_position.is_a?(Hash) - new_position = new_position.with_indifferent_access - new_position = Gitlab::Diff::Position.new(new_position) + super(new_position) end - - super(new_position) end def diff_file @@ -88,43 +65,6 @@ class DiffNote < Note self.position.diff_refs == diff_refs end - # If you update this method remember to also update the scope `resolvable` - def resolvable? - !system? && for_merge_request? - end - - def resolved? - return false unless resolvable? - - self.resolved_at.present? - end - - # If you update this method remember to also update `.resolve!` - def resolve!(current_user) - return unless resolvable? - return if resolved? - - self.resolved_at = Time.now - self.resolved_by = current_user - save! - end - - # If you update this method remember to also update `.unresolve!` - def unresolve! - return unless resolvable? - return unless resolved? - - self.resolved_at = nil - self.resolved_by = nil - save! - end - - def discussion - return unless resolvable? - - self.noteable.find_diff_discussion(self.discussion_id) - end - private def supported? @@ -140,33 +80,13 @@ class DiffNote < Note end def set_original_position - self.original_position = self.position.dup + self.original_position = self.position.dup unless self.original_position&.complete? end def set_line_code self.line_code = self.position.line_code(self.project.repository) end - def ensure_original_discussion_id - return unless self.persisted? - return if self.original_discussion_id - - set_original_discussion_id - update_column(:original_discussion_id, self.original_discussion_id) - end - - def set_original_discussion_id - self.original_discussion_id = Digest::SHA1.hexdigest(build_original_discussion_id) - end - - def build_discussion_id - self.class.build_discussion_id(noteable_type, noteable_id || commit_id, position) - end - - def build_original_discussion_id - self.class.build_discussion_id(noteable_type, noteable_id || commit_id, original_position) - end - def update_position return unless supported? return if for_commit? diff --git a/app/models/discussion.rb b/app/models/discussion.rb index bbe813db823..2c0e6379e6a 100644 --- a/app/models/discussion.rb +++ b/app/models/discussion.rb @@ -1,7 +1,10 @@ +# A non-diff discussion on an issue, merge request, commit, or snippet, consisting of `DiscussionNote` notes. +# +# A discussion of this type can be resolvable. class Discussion - NUMBER_OF_TRUNCATED_DIFF_LINES = 16 + include ResolvableDiscussion - attr_reader :notes + attr_reader :notes, :noteable delegate :created_at, :project, @@ -11,43 +14,62 @@ class Discussion :for_commit?, :for_merge_request?, - :line_code, - :original_line_code, - :diff_file, - :for_line?, - :active?, - to: :first_note - delegate :resolved_at, - :resolved_by, + def self.build(notes, noteable = nil) + notes.first.discussion_class(noteable).new(notes, noteable) + end - to: :last_resolved_note, - allow_nil: true + def self.build_collection(notes, noteable = nil) + notes.group_by { |n| n.discussion_id(noteable) }.values.map { |notes| build(notes, noteable) } + end - delegate :blob, - :highlighted_diff_lines, - :diff_lines, + # Returns an alphanumeric discussion ID based on `build_discussion_id` + def self.discussion_id(note) + Digest::SHA1.hexdigest(build_discussion_id(note).join("-")) + end - to: :diff_file, - allow_nil: true + # Returns an array of discussion ID components + def self.build_discussion_id(note) + [*base_discussion_id(note), SecureRandom.hex] + end - def self.for_notes(notes) - notes.group_by(&:discussion_id).values.map { |notes| new(notes) } + def self.base_discussion_id(note) + noteable_id = note.noteable_id || note.commit_id + [:discussion, note.noteable_type.try(:underscore), noteable_id] end - def self.for_diff_notes(notes) - notes.group_by(&:line_code).values.map { |notes| new(notes) } + # When notes on a commit are displayed in context of a merge request that contains that commit, + # these notes are to be displayed as if they were part of one discussion, even though they were actually + # individual notes on the commit with different discussion IDs, so that it's clear that these are not + # notes on the merge request itself. + # + # To turn a list of notes into a list of discussions, they are grouped by discussion ID, so to + # get these out-of-context notes to end up in the same discussion, we need to get them to return the same + # `discussion_id` when this grouping happens. To enable this, `Note#discussion_id` calls out + # to the `override_discussion_id` method on the appropriate `Discussion` subclass, as determined by + # the `discussion_class` method on `Note` or a subclass of `Note`. + # + # If no override is necessary, return `nil`. + # For the case described above, see `OutOfContextDiscussion.override_discussion_id`. + def self.override_discussion_id(note) + nil end - def initialize(notes) - @notes = notes + def self.note_class + DiscussionNote end - def last_resolved_note - return unless resolved? + def initialize(notes, noteable = nil) + @notes = notes + @noteable = noteable + end - @last_resolved_note ||= resolved_notes.sort_by(&:resolved_at).last + def ==(other) + other.class == self.class && + other.noteable == self.noteable && + other.id == self.id && + other.notes == self.notes end def last_updated_at @@ -59,91 +81,29 @@ class Discussion end def id - first_note.discussion_id + first_note.discussion_id(noteable) end alias_method :to_param, :id def diff_discussion? - first_note.diff_note? - end - - def legacy_diff_discussion? - notes.any?(&:legacy_diff_note?) + false end - def resolvable? - return @resolvable if @resolvable.present? - - @resolvable = diff_discussion? && notes.any?(&:resolvable?) + def individual_note? + false end - def resolved? - return @resolved if @resolved.present? - - @resolved = resolvable? && notes.none?(&:to_be_resolved?) - end - - def first_note - @first_note ||= @notes.first - end - - def first_note_to_resolve - @first_note_to_resolve ||= notes.detect(&:to_be_resolved?) + def new_discussion? + notes.length == 1 end def last_note - @last_note ||= @notes.last - end - - def resolved_notes - notes.select(&:resolved?) - end - - def to_be_resolved? - resolvable? && !resolved? - end - - def can_resolve?(current_user) - return false unless current_user - return false unless resolvable? - - current_user == self.noteable.author || - current_user.can?(:resolve_note, self.project) - end - - def resolve!(current_user) - return unless resolvable? - - update { |notes| notes.resolve!(current_user) } - end - - def unresolve! - return unless resolvable? - - update { |notes| notes.unresolve! } - end - - def for_target?(target) - self.noteable == target && !diff_discussion? - end - - def active? - return @active if @active.present? - - @active = first_note.active? + @last_note ||= notes.last end def collapsed? - return false unless diff_discussion? - - if resolvable? - # New diff discussions only disappear once they are marked resolved - resolved? - else - # Old diff discussions disappear once they become outdated - !active? - end + resolved? end def expanded? @@ -151,52 +111,6 @@ class Discussion end def reply_attributes - data = { - noteable_type: first_note.noteable_type, - noteable_id: first_note.noteable_id, - commit_id: first_note.commit_id, - discussion_id: self.id, - } - - if diff_discussion? - data[:note_type] = first_note.type - - data.merge!(first_note.diff_attributes) - end - - data - end - - # Returns an array of at most 16 highlighted lines above a diff note - def truncated_diff_lines(highlight: true) - lines = highlight ? highlighted_diff_lines : diff_lines - prev_lines = [] - - lines.each do |line| - if line.meta? - prev_lines.clear - else - prev_lines << line - - break if for_line?(line) - - prev_lines.shift if prev_lines.length >= NUMBER_OF_TRUNCATED_DIFF_LINES - end - end - - prev_lines - end - - private - - def update - notes_relation = DiffNote.where(id: notes.map(&:id)).fresh - yield(notes_relation) - - # Set the notes array to the updated notes - @notes = notes_relation.to_a - - # Reset the memoized values - @last_resolved_note = @resolvable = @resolved = @first_note = @last_note = nil + first_note.slice(:type, :noteable_type, :noteable_id, :commit_id, :discussion_id) end end diff --git a/app/models/discussion_note.rb b/app/models/discussion_note.rb new file mode 100644 index 00000000000..e660b024083 --- /dev/null +++ b/app/models/discussion_note.rb @@ -0,0 +1,13 @@ +# A note in a non-diff discussion on an issue, merge request, commit, or snippet. +# +# A note of this type can be resolvable. +class DiscussionNote < Note + # Names of all implementers of `Noteable` that support discussions. + NOTEABLE_TYPES = %w(MergeRequest Issue Commit Snippet).freeze + + validates :noteable_type, inclusion: { in: NOTEABLE_TYPES } + + def discussion_class(*) + Discussion + end +end diff --git a/app/models/group.rb b/app/models/group.rb index 60274386103..106084175ff 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -27,11 +27,14 @@ class Group < Namespace validates :avatar, file_size: { maximum: 200.kilobytes.to_i } + validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 } + mount_uploader :avatar, AvatarUploader has_many :uploads, as: :model, dependent: :destroy after_create :post_create_hook after_destroy :post_destroy_hook + after_save :update_two_factor_requirement class << self # Searches for groups matching the given query. @@ -223,4 +226,12 @@ class Group < Namespace type: public? ? 'O' : 'I' # Open vs Invite-only } end + + protected + + def update_two_factor_requirement + return unless require_two_factor_authentication_changed? || two_factor_grace_period_changed? + + users.find_each(&:update_two_factor_requirement) + end end diff --git a/app/models/individual_note_discussion.rb b/app/models/individual_note_discussion.rb new file mode 100644 index 00000000000..c3f21c55240 --- /dev/null +++ b/app/models/individual_note_discussion.rb @@ -0,0 +1,13 @@ +# A discussion to wrap a single `Note` note on the root of an issue, merge request, +# commit, or snippet, that is not displayed as a discussion. +# +# A discussion of this type is never resolvable. +class IndividualNoteDiscussion < Discussion + def self.note_class + Note + end + + def individual_note? + true + end +end diff --git a/app/models/issue.rb b/app/models/issue.rb index f9704b0d754..d8d9db477d2 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -3,6 +3,7 @@ require 'carrierwave/orm/activerecord' class Issue < ActiveRecord::Base include InternalId include Issuable + include Noteable include Referable include Sortable include Spammable diff --git a/app/models/legacy_diff_discussion.rb b/app/models/legacy_diff_discussion.rb new file mode 100644 index 00000000000..cb2651a03f8 --- /dev/null +++ b/app/models/legacy_diff_discussion.rb @@ -0,0 +1,25 @@ +# A discussion on merge request or commit diffs consisting of `LegacyDiffNote` notes. +# +# All new diff discussions are of the type `DiffDiscussion`, but any diff discussions created +# before the introduction of the new implementation still use `LegacyDiffDiscussion`. +# +# A discussion of this type is never resolvable. +class LegacyDiffDiscussion < Discussion + include DiscussionOnDiff + + def legacy_diff_discussion? + true + end + + def self.note_class + LegacyDiffNote + end + + def collapsed? + !active? + end + + def reply_attributes + super.merge(line_code: line_code) + end +end diff --git a/app/models/legacy_diff_note.rb b/app/models/legacy_diff_note.rb index 40277a9b139..9a77557ebcd 100644 --- a/app/models/legacy_diff_note.rb +++ b/app/models/legacy_diff_note.rb @@ -1,3 +1,9 @@ +# A note on merge request or commit diffs, using the legacy implementation. +# +# All new diff notes are of the type `DiffNote`, but any diff notes created +# before the introduction of the new implementation still use `LegacyDiffNote`. +# +# A note of this type is never resolvable. class LegacyDiffNote < Note include NoteOnDiff @@ -7,18 +13,8 @@ class LegacyDiffNote < Note before_create :set_diff - class << self - def build_discussion_id(noteable_type, noteable_id, line_code) - [super(noteable_type, noteable_id), line_code].join("-") - end - end - - def legacy_diff_note? - true - end - - def diff_attributes - { line_code: line_code } + def discussion_class(*) + LegacyDiffDiscussion end def project_repository @@ -119,8 +115,4 @@ class LegacyDiffNote < Note diffs = noteable.raw_diffs(Commit.max_diff_options) diffs.find { |d| d.new_path == self.diff.new_path } end - - def build_discussion_id - self.class.build_discussion_id(noteable_type, noteable_id || commit_id, line_code) - end end diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index 446f9f8f8a7..483425cd30f 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -3,11 +3,16 @@ class GroupMember < Member belongs_to :group, foreign_key: 'source_id' + delegate :update_two_factor_requirement, to: :user + # Make sure group member points only to group as it source default_value_for :source_type, SOURCE_TYPE validates :source_type, format: { with: /\ANamespace\z/ } default_scope { where(source_type: SOURCE_TYPE) } + after_create :update_two_factor_requirement, unless: :invite? + after_destroy :update_two_factor_requirement, unless: :invite? + def self.access_level_roles Gitlab::Access.options_with_owner end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 8d740adb771..77c902fdf09 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -1,6 +1,7 @@ class MergeRequest < ActiveRecord::Base include InternalId include Issuable + include Noteable include Referable include Sortable @@ -475,43 +476,7 @@ class MergeRequest < ActiveRecord::Base ) end - def discussions - @discussions ||= self.related_notes. - inc_relations_for_view. - fresh. - discussions - end - - def diff_discussions - @diff_discussions ||= self.notes.diff_notes.discussions - end - - def resolvable_discussions - @resolvable_discussions ||= diff_discussions.select(&:to_be_resolved?) - end - - def discussions_can_be_resolved_by?(user) - resolvable_discussions.all? { |discussion| discussion.can_resolve?(user) } - end - - def find_diff_discussion(discussion_id) - notes = self.notes.diff_notes.where(discussion_id: discussion_id).fresh.to_a - return if notes.empty? - - Discussion.new(notes) - end - - def discussions_resolvable? - diff_discussions.any?(&:resolvable?) - end - - def discussions_resolved? - discussions_resolvable? && diff_discussions.none?(&:to_be_resolved?) - end - - def discussions_to_be_resolved? - discussions_resolvable? && !discussions_resolved? - end + alias_method :discussion_notes, :related_notes def mergeable_discussions_state? return true unless project.only_allow_merge_if_all_discussions_are_resolved? @@ -857,8 +822,8 @@ class MergeRequest < ActiveRecord::Base return unless has_complete_diff_refs? return if new_diff_refs == old_diff_refs - active_diff_notes = self.notes.diff_notes.select do |note| - note.new_diff_note? && note.active?(old_diff_refs) + active_diff_notes = self.notes.new_diff_notes.select do |note| + note.active?(old_diff_refs) end return if active_diff_notes.empty? diff --git a/app/models/note.rb b/app/models/note.rb index 16d66cb1427..401e3d7bcbc 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -1,3 +1,6 @@ +# A note on the root of an issue, merge request, commit, or snippet. +# +# A note of this type is never resolvable. class Note < ActiveRecord::Base extend ActiveModel::Naming include Gitlab::CurrentSettings @@ -8,6 +11,10 @@ class Note < ActiveRecord::Base include FasterCacheKeys include CacheMarkdownField include AfterCommitQueue + include ResolvableNote + include IgnorableColumn + + ignore_column :original_discussion_id cache_markdown_field :note, pipeline: :note @@ -32,9 +39,6 @@ class Note < ActiveRecord::Base belongs_to :author, class_name: "User" belongs_to :updated_by, class_name: "User" - # Only used by DiffNote, but defined here so that it can be used in `Note.includes` - belongs_to :resolved_by, class_name: "User" - has_many :todos, dependent: :destroy has_many :events, as: :target, dependent: :destroy has_one :system_note_metadata @@ -54,10 +58,11 @@ class Note < ActiveRecord::Base validates :noteable_id, presence: true, unless: [:for_commit?, :importing?] validates :commit_id, presence: true, if: :for_commit? validates :author, presence: true + validates :discussion_id, presence: true, format: { with: /\A\h{40}\z/ } validate unless: [:for_commit?, :importing?, :for_personal_snippet?] do |note| unless note.noteable.try(:project) == note.project - errors.add(:invalid_project, 'Note and noteable project mismatch') + errors.add(:project, 'does not match noteable project') end end @@ -69,6 +74,7 @@ class Note < ActiveRecord::Base scope :user, ->{ where(system: false) } scope :common, ->{ where(noteable_type: ["", nil]) } scope :fresh, ->{ order(created_at: :asc, id: :asc) } + scope :updated_after, ->(time){ where('updated_at > ?', time) } scope :inc_author_project, ->{ includes(:project, :author) } scope :inc_author, ->{ includes(:author) } scope :inc_relations_for_view, -> do @@ -76,7 +82,8 @@ class Note < ActiveRecord::Base end scope :diff_notes, ->{ where(type: %w(LegacyDiffNote DiffNote)) } - scope :non_diff_notes, ->{ where(type: ['Note', nil]) } + scope :new_diff_notes, ->{ where(type: 'DiffNote') } + scope :non_diff_notes, ->{ where(type: ['Note', 'DiscussionNote', nil]) } scope :with_associations, -> do # FYI noteable cannot be loaded for LegacyDiffNote for commits @@ -86,7 +93,7 @@ class Note < ActiveRecord::Base after_initialize :ensure_discussion_id before_validation :nullify_blank_type, :nullify_blank_line_code - before_validation :set_discussion_id + before_validation :set_discussion_id, on: :create after_save :keep_around_commit, unless: :for_personal_snippet? after_save :expire_etag_cache @@ -95,22 +102,23 @@ class Note < ActiveRecord::Base ActiveModel::Name.new(self, nil, 'note') end - def build_discussion_id(noteable_type, noteable_id) - [:discussion, noteable_type.try(:underscore), noteable_id].join("-") + def discussions(noteable = nil) + Discussion.build_collection(fresh, noteable) end - def discussion_id(*args) - Digest::SHA1.hexdigest(build_discussion_id(*args)) - end + def find_discussion(discussion_id) + notes = where(discussion_id: discussion_id).fresh.to_a + return if notes.empty? - def discussions - Discussion.for_notes(fresh) + Discussion.build(notes) end def grouped_diff_discussions - active_notes = diff_notes.fresh.select(&:active?) - Discussion.for_diff_notes(active_notes). - map { |d| [d.line_code, d] }.to_h + diff_notes. + fresh. + discussions. + select(&:active?). + group_by(&:line_code) end def count_for_collection(ids, type) @@ -121,37 +129,17 @@ class Note < ActiveRecord::Base end def cross_reference? - system && SystemNoteService.cross_reference?(note) + system? && SystemNoteService.cross_reference?(note) end def diff_note? false end - def legacy_diff_note? - false - end - - def new_diff_note? - false - end - def active? true end - def resolvable? - false - end - - def resolved? - false - end - - def to_be_resolved? - resolvable? && !resolved? - end - def max_attachment_size current_application_settings.max_attachment_size.megabytes.to_i end @@ -228,7 +216,7 @@ class Note < ActiveRecord::Base end def can_be_award_emoji? - noteable.is_a?(Awardable) + noteable.is_a?(Awardable) && !part_of_discussion? end def contains_emoji_only? @@ -239,6 +227,63 @@ class Note < ActiveRecord::Base for_personal_snippet? ? 'personal_snippet' : noteable_type.underscore end + def can_be_discussion_note? + self.noteable.supports_discussions? && !part_of_discussion? + end + + def discussion_class(noteable = nil) + # When commit notes are rendered on an MR's Discussion page, they are + # displayed in one discussion instead of individually. + # See also `#discussion_id` and `Discussion.override_discussion_id`. + if noteable && noteable != self.noteable + OutOfContextDiscussion + else + IndividualNoteDiscussion + end + end + + # See `Discussion.override_discussion_id` for details. + def discussion_id(noteable = nil) + discussion_class(noteable).override_discussion_id(self) || super() + end + + # Returns a discussion containing just this note. + # This method exists as an alternative to `#discussion` to use when the methods + # we intend to call on the Discussion object don't require it to have all of its notes, + # and just depend on the first note or the type of discussion. This saves us a DB query. + def to_discussion(noteable = nil) + Discussion.build([self], noteable) + end + + # Returns the entire discussion this note is part of. + # Consider using `#to_discussion` if we do not need to render the discussion + # and all its notes and if we don't care about the discussion's resolvability status. + def discussion + full_discussion = self.noteable.notes.find_discussion(self.discussion_id) if part_of_discussion? + full_discussion || to_discussion + end + + def part_of_discussion? + !to_discussion.individual_note? + end + + def in_reply_to?(other) + case other + when Note + if part_of_discussion? + in_reply_to?(other.noteable) && in_reply_to?(other.to_discussion) + else + in_reply_to?(other.noteable) + end + when Discussion + self.discussion_id == other.id + when Noteable + self.noteable == other + else + false + end + end + private def keep_around_commit @@ -264,17 +309,7 @@ class Note < ActiveRecord::Base end def set_discussion_id - self.discussion_id = Digest::SHA1.hexdigest(build_discussion_id) - end - - def build_discussion_id - if for_merge_request? - # Notes on merge requests are always in a discussion of their own, - # so we generate a unique discussion ID. - [:discussion, :note, SecureRandom.hex].join("-") - else - self.class.build_discussion_id(noteable_type, noteable_id || commit_id) - end + self.discussion_id ||= discussion_class.discussion_id(self) end def expire_etag_cache diff --git a/app/models/out_of_context_discussion.rb b/app/models/out_of_context_discussion.rb new file mode 100644 index 00000000000..85794630f70 --- /dev/null +++ b/app/models/out_of_context_discussion.rb @@ -0,0 +1,22 @@ +# When notes on a commit are displayed in the context of a merge request that +# contains that commit, they are displayed as if they were a discussion. +# +# This represents one of those discussions, consisting of `Note` notes. +# +# A discussion of this type is never resolvable. +class OutOfContextDiscussion < Discussion + # Returns an array of discussion ID components + def self.build_discussion_id(note) + base_discussion_id(note) + end + + # To make sure all out-of-context notes end up grouped as one discussion, + # we override the discussion ID to be a newly generated but consistent ID. + def self.override_discussion_id(note) + discussion_id(note) + end + + def self.note_class + Note + end +end diff --git a/app/models/project.rb b/app/models/project.rb index 1f95d00baf8..19835a72a75 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -116,6 +116,7 @@ class Project < ActiveRecord::Base has_one :mock_ci_service, dependent: :destroy has_one :mock_deployment_service, dependent: :destroy has_one :mock_monitoring_service, dependent: :destroy + has_one :microsoft_teams_service, dependent: :destroy has_one :forked_project_link, dependent: :destroy, foreign_key: "forked_to_project_id" has_one :forked_from_project, through: :forked_project_link @@ -159,6 +160,7 @@ class Project < ActiveRecord::Base has_one :import_data, dependent: :destroy, class_name: "ProjectImportData" has_one :project_feature, dependent: :destroy has_one :statistics, class_name: 'ProjectStatistics', dependent: :delete + has_many :container_repositories, dependent: :destroy has_many :commit_statuses, dependent: :destroy has_many :pipelines, dependent: :destroy, class_name: 'Ci::Pipeline' @@ -406,32 +408,15 @@ class Project < ActiveRecord::Base @repository ||= Repository.new(path_with_namespace, self) end - def container_registry_path_with_namespace - path_with_namespace.downcase - end - - def container_registry_repository - return unless Gitlab.config.registry.enabled - - @container_registry_repository ||= begin - token = Auth::ContainerRegistryAuthenticationService.full_access_token(container_registry_path_with_namespace) - url = Gitlab.config.registry.api_url - host_port = Gitlab.config.registry.host_port - registry = ContainerRegistry::Registry.new(url, token: token, path: host_port) - registry.repository(container_registry_path_with_namespace) - end - end - - def container_registry_repository_url + def container_registry_url if Gitlab.config.registry.enabled - "#{Gitlab.config.registry.host_port}/#{container_registry_path_with_namespace}" + "#{Gitlab.config.registry.host_port}/#{path_with_namespace.downcase}" end end def has_container_registry_tags? - return unless container_registry_repository - - container_registry_repository.tags.any? + container_repositories.to_a.any?(&:has_tags?) || + has_root_container_repository_tags? end def commit(ref = 'HEAD') @@ -922,10 +907,10 @@ class Project < ActiveRecord::Base expire_caches_before_rename(old_path_with_namespace) if has_container_registry_tags? - Rails.logger.error "Project #{old_path_with_namespace} cannot be renamed because container registry tags are present" + Rails.logger.error "Project #{old_path_with_namespace} cannot be renamed because container registry tags are present!" - # we currently doesn't support renaming repository if it contains tags in container registry - raise StandardError.new('Project cannot be renamed, because tags are present in its container registry') + # we currently doesn't support renaming repository if it contains images in container registry + raise StandardError.new('Project cannot be renamed, because images are present in its container registry') end if gitlab_shell.mv_repository(repository_storage_path, old_path_with_namespace, new_path_with_namespace) @@ -1115,10 +1100,6 @@ class Project < ActiveRecord::Base self.runners_token && ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.runners_token) end - def build_coverage_enabled? - build_coverage_regex.present? - end - def build_timeout_in_minutes build_timeout / 60 end @@ -1272,7 +1253,7 @@ class Project < ActiveRecord::Base ] if container_registry_enabled? - variables << { key: 'CI_REGISTRY_IMAGE', value: container_registry_repository_url, public: true } + variables << { key: 'CI_REGISTRY_IMAGE', value: container_registry_url, public: true } end variables @@ -1405,4 +1386,15 @@ class Project < ActiveRecord::Base Project.unscoped.where(pending_delete: true).find_by_full_path(path_with_namespace) end + + ## + # This method is here because of support for legacy container repository + # which has exactly the same path like project does, but which might not be + # persisted in `container_repositories` table. + # + def has_root_container_repository_tags? + return false unless Gitlab.config.registry.enabled + + ContainerRepository.build_root_repository(self).has_tags? + end end diff --git a/app/models/project_services/chat_message/base_message.rb b/app/models/project_services/chat_message/base_message.rb index 86d271a3f69..7621a5fa2d8 100644 --- a/app/models/project_services/chat_message/base_message.rb +++ b/app/models/project_services/chat_message/base_message.rb @@ -2,11 +2,23 @@ require 'slack-notifier' module ChatMessage class BaseMessage + attr_reader :markdown + attr_reader :user_name + attr_reader :user_avatar + attr_reader :project_name + attr_reader :project_url + def initialize(params) - raise NotImplementedError + @markdown = params[:markdown] || false + @project_name = params.dig(:project, :path_with_namespace) || params[:project_name] + @project_url = params.dig(:project, :web_url) || params[:project_url] + @user_name = params.dig(:user, :username) || params[:user_name] + @user_avatar = params.dig(:user, :avatar_url) || params[:user_avatar] end def pretext + return message if markdown + format(message) end @@ -17,6 +29,10 @@ module ChatMessage raise NotImplementedError end + def activity + raise NotImplementedError + end + private def message diff --git a/app/models/project_services/chat_message/issue_message.rb b/app/models/project_services/chat_message/issue_message.rb index 791e5b0cec7..4b9a2b1e1f3 100644 --- a/app/models/project_services/chat_message/issue_message.rb +++ b/app/models/project_services/chat_message/issue_message.rb @@ -1,9 +1,6 @@ module ChatMessage class IssueMessage < BaseMessage - attr_reader :user_name attr_reader :title - attr_reader :project_name - attr_reader :project_url attr_reader :issue_iid attr_reader :issue_url attr_reader :action @@ -11,9 +8,7 @@ module ChatMessage attr_reader :description def initialize(params) - @user_name = params[:user][:username] - @project_name = params[:project_name] - @project_url = params[:project_url] + super obj_attr = params[:object_attributes] obj_attr = HashWithIndifferentAccess.new(obj_attr) @@ -27,15 +22,24 @@ module ChatMessage def attachments return [] unless opened_issue? + return description if markdown description_message end + def activity + { + title: "Issue #{state} by #{user_name}", + subtitle: "in #{project_link}", + text: issue_link, + image: user_avatar + } + end + private def message - case state - when "opened" + if state == 'opened' "[#{project_link}] Issue #{state} by #{user_name}" else "[#{project_link}] Issue #{issue_link} #{state} by #{user_name}" @@ -64,7 +68,7 @@ module ChatMessage end def issue_title - "##{issue_iid} #{title}" + "#{Issue.reference_prefix}#{issue_iid} #{title}" end end end diff --git a/app/models/project_services/chat_message/merge_message.rb b/app/models/project_services/chat_message/merge_message.rb index 5e5efca7bec..7d0de81cdf0 100644 --- a/app/models/project_services/chat_message/merge_message.rb +++ b/app/models/project_services/chat_message/merge_message.rb @@ -1,36 +1,36 @@ module ChatMessage class MergeMessage < BaseMessage - attr_reader :user_name - attr_reader :project_name - attr_reader :project_url - attr_reader :merge_request_id + attr_reader :merge_request_iid attr_reader :source_branch attr_reader :target_branch attr_reader :state attr_reader :title def initialize(params) - @user_name = params[:user][:username] - @project_name = params[:project_name] - @project_url = params[:project_url] + super obj_attr = params[:object_attributes] obj_attr = HashWithIndifferentAccess.new(obj_attr) - @merge_request_id = obj_attr[:iid] + @merge_request_iid = obj_attr[:iid] @source_branch = obj_attr[:source_branch] @target_branch = obj_attr[:target_branch] @state = obj_attr[:state] @title = format_title(obj_attr[:title]) end - def pretext - format(message) - end - def attachments [] end + def activity + { + title: "Merge Request #{state} by #{user_name}", + subtitle: "in #{project_link}", + text: merge_request_link, + image: user_avatar + } + end + private def format_title(title) @@ -50,11 +50,15 @@ module ChatMessage end def merge_request_link - link("merge request !#{merge_request_id}", merge_request_url) + link(merge_request_title, merge_request_url) + end + + def merge_request_title + "#{MergeRequest.reference_prefix}#{merge_request_iid} #{title}" end def merge_request_url - "#{project_url}/merge_requests/#{merge_request_id}" + "#{project_url}/merge_requests/#{merge_request_iid}" end end end diff --git a/app/models/project_services/chat_message/note_message.rb b/app/models/project_services/chat_message/note_message.rb index 552113bac29..2da4c244229 100644 --- a/app/models/project_services/chat_message/note_message.rb +++ b/app/models/project_services/chat_message/note_message.rb @@ -1,70 +1,74 @@ module ChatMessage class NoteMessage < BaseMessage - attr_reader :message - attr_reader :user_name - attr_reader :project_name - attr_reader :project_url attr_reader :note attr_reader :note_url + attr_reader :title + attr_reader :target def initialize(params) - params = HashWithIndifferentAccess.new(params) - @user_name = params[:user][:username] - @project_name = params[:project_name] - @project_url = params[:project_url] + super + params = HashWithIndifferentAccess.new(params) obj_attr = params[:object_attributes] - obj_attr = HashWithIndifferentAccess.new(obj_attr) @note = obj_attr[:note] @note_url = obj_attr[:url] - noteable_type = obj_attr[:noteable_type] - - case noteable_type - when "Commit" - create_commit_note(HashWithIndifferentAccess.new(params[:commit])) - when "Issue" - create_issue_note(HashWithIndifferentAccess.new(params[:issue])) - when "MergeRequest" - create_merge_note(HashWithIndifferentAccess.new(params[:merge_request])) - when "Snippet" - create_snippet_note(HashWithIndifferentAccess.new(params[:snippet])) - end + @target, @title = case obj_attr[:noteable_type] + when "Commit" + create_commit_note(params[:commit]) + when "Issue" + create_issue_note(params[:issue]) + when "MergeRequest" + create_merge_note(params[:merge_request]) + when "Snippet" + create_snippet_note(params[:snippet]) + end end def attachments + return note if markdown + description_message end + def activity + { + title: "#{user_name} #{link('commented on ' + target, note_url)}", + subtitle: "in #{project_link}", + text: formatted_title, + image: user_avatar + } + end + private + def message + "#{user_name} #{link('commented on ' + target, note_url)} in #{project_link}: *#{formatted_title}*" + end + def format_title(title) title.lines.first.chomp end - def create_commit_note(commit) - commit_sha = commit[:id] - commit_sha = Commit.truncate_sha(commit_sha) - commented_on_message( - "commit #{commit_sha}", - format_title(commit[:message])) + def formatted_title + format_title(title) end def create_issue_note(issue) - commented_on_message( - "issue ##{issue[:iid]}", - format_title(issue[:title])) + ["issue #{Issue.reference_prefix}#{issue[:iid]}", issue[:title]] + end + + def create_commit_note(commit) + commit_sha = Commit.truncate_sha(commit[:id]) + + ["commit #{commit_sha}", commit[:message]] end def create_merge_note(merge_request) - commented_on_message( - "merge request !#{merge_request[:iid]}", - format_title(merge_request[:title])) + ["merge request #{MergeRequest.reference_prefix}#{merge_request[:iid]}", merge_request[:title]] end def create_snippet_note(snippet) - commented_on_message( - "snippet ##{snippet[:id]}", - format_title(snippet[:title])) + ["snippet #{Snippet.reference_prefix}#{snippet[:id]}", snippet[:title]] end def description_message @@ -74,9 +78,5 @@ module ChatMessage def project_link link(project_name, project_url) end - - def commented_on_message(target, title) - @message = "#{user_name} #{link('commented on ' + target, note_url)} in #{project_link}: *#{title}*" - end end end diff --git a/app/models/project_services/chat_message/pipeline_message.rb b/app/models/project_services/chat_message/pipeline_message.rb index 210027565a8..4628d9b1a7b 100644 --- a/app/models/project_services/chat_message/pipeline_message.rb +++ b/app/models/project_services/chat_message/pipeline_message.rb @@ -1,19 +1,22 @@ module ChatMessage class PipelineMessage < BaseMessage - attr_reader :ref_type, :ref, :status, :project_name, :project_url, - :user_name, :duration, :pipeline_id + attr_reader :ref_type + attr_reader :ref + attr_reader :status + attr_reader :duration + attr_reader :pipeline_id def initialize(data) + super + + @user_name = data.dig(:user, :name) || 'API' + pipeline_attributes = data[:object_attributes] @ref_type = pipeline_attributes[:tag] ? 'tag' : 'branch' @ref = pipeline_attributes[:ref] @status = pipeline_attributes[:status] @duration = pipeline_attributes[:duration] @pipeline_id = pipeline_attributes[:id] - - @project_name = data[:project][:path_with_namespace] - @project_url = data[:project][:web_url] - @user_name = (data[:user] && data[:user][:name]) || 'API' end def pretext @@ -25,17 +28,24 @@ module ChatMessage end def attachments + return message if markdown + [{ text: format(message), color: attachment_color }] end + def activity + { + title: "Pipeline #{pipeline_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status}", + subtitle: "in #{project_link}", + text: "in #{duration} #{time_measure}", + image: user_avatar || '' + } + end + private def message - "#{project_link}: Pipeline #{pipeline_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status} in #{duration} #{'second'.pluralize(duration)}" - end - - def format(string) - Slack::Notifier::LinkFormatter.format(string) + "#{project_link}: Pipeline #{pipeline_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status} in #{duration} #{time_measure}" end def humanized_status @@ -74,5 +84,9 @@ module ChatMessage def pipeline_link "[##{pipeline_id}](#{pipeline_url})" end + + def time_measure + 'second'.pluralize(duration) + end end end diff --git a/app/models/project_services/chat_message/push_message.rb b/app/models/project_services/chat_message/push_message.rb index 2d73b71ec37..c52dd6ef8ef 100644 --- a/app/models/project_services/chat_message/push_message.rb +++ b/app/models/project_services/chat_message/push_message.rb @@ -3,33 +3,43 @@ module ChatMessage attr_reader :after attr_reader :before attr_reader :commits - attr_reader :project_name - attr_reader :project_url attr_reader :ref attr_reader :ref_type - attr_reader :user_name def initialize(params) + super + @after = params[:after] @before = params[:before] @commits = params.fetch(:commits, []) - @project_name = params[:project_name] - @project_url = params[:project_url] @ref_type = Gitlab::Git.tag_ref?(params[:ref]) ? 'tag' : 'branch' @ref = Gitlab::Git.ref_name(params[:ref]) - @user_name = params[:user_name] - end - - def pretext - format(message) end def attachments return [] if new_branch? || removed_branch? + return commit_messages if markdown commit_message_attachments end + def activity + action = if new_branch? + "created" + elsif removed_branch? + "removed" + else + "pushed to" + end + + { + title: "#{user_name} #{action} #{ref_type}", + subtitle: "in #{project_link}", + text: compare_link, + image: user_avatar + } + end + private def message @@ -59,7 +69,7 @@ module ChatMessage end def commit_messages - commits.map { |commit| compose_commit_message(commit) }.join("\n") + commits.map { |commit| compose_commit_message(commit) }.join("\n\n") end def commit_message_attachments diff --git a/app/models/project_services/chat_message/wiki_page_message.rb b/app/models/project_services/chat_message/wiki_page_message.rb index 134083e4504..a139a8ee727 100644 --- a/app/models/project_services/chat_message/wiki_page_message.rb +++ b/app/models/project_services/chat_message/wiki_page_message.rb @@ -1,17 +1,12 @@ module ChatMessage class WikiPageMessage < BaseMessage - attr_reader :user_name attr_reader :title - attr_reader :project_name - attr_reader :project_url attr_reader :wiki_page_url attr_reader :action attr_reader :description def initialize(params) - @user_name = params[:user][:username] - @project_name = params[:project_name] - @project_url = params[:project_url] + super obj_attr = params[:object_attributes] obj_attr = HashWithIndifferentAccess.new(obj_attr) @@ -29,9 +24,20 @@ module ChatMessage end def attachments + return description if markdown + description_message end + def activity + { + title: "#{user_name} #{action} #{wiki_page_link}", + subtitle: "in #{project_link}", + text: title, + image: user_avatar + } + end + private def message diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb index 75834103db5..fa782c6fbb7 100644 --- a/app/models/project_services/chat_notification_service.rb +++ b/app/models/project_services/chat_notification_service.rb @@ -49,10 +49,7 @@ class ChatNotificationService < Service object_kind = data[:object_kind] - data = data.merge( - project_url: project_url, - project_name: project_name - ) + data = custom_data(data) # WebHook events often have an 'update' event that follows a 'open' or # 'close' action. Ignore update events for now to prevent duplicate @@ -68,8 +65,7 @@ class ChatNotificationService < Service opts[:channel] = channel_name if channel_name opts[:username] = username if username - notifier = Slack::Notifier.new(webhook, opts) - notifier.ping(message.pretext, attachments: message.attachments, fallback: message.fallback) + return false unless notify(message, opts) true end @@ -92,6 +88,18 @@ class ChatNotificationService < Service private + def notify(message, opts) + Slack::Notifier.new(webhook, opts).ping( + message.pretext, + attachments: message.attachments, + fallback: message.fallback + ) + end + + def custom_data(data) + data.merge(project_url: project_url, project_name: project_name) + end + def get_message(object_kind, data) case object_kind when "push", "tag_push" diff --git a/app/models/project_services/microsoft_teams_service.rb b/app/models/project_services/microsoft_teams_service.rb new file mode 100644 index 00000000000..9b218fd81b4 --- /dev/null +++ b/app/models/project_services/microsoft_teams_service.rb @@ -0,0 +1,56 @@ +class MicrosoftTeamsService < ChatNotificationService + def title + 'Microsoft Teams Notification' + end + + def description + 'Receive event notifications in Microsoft Teams' + end + + def self.to_param + 'microsoft_teams' + end + + def help + 'This service sends notifications about projects events to Microsoft Teams channels.<br /> + To set up this service: + <ol> + <li><a href="https://msdn.microsoft.com/en-us/microsoft-teams/connectors">Getting started with 365 Office Connectors For Microsoft Teams</a>.</li> + <li>Paste the <strong>Webhook URL</strong> into the field below.</li> + <li>Select events below to enable notifications.</li> + </ol>' + end + + def webhook_placeholder + 'https://outlook.office.com/webhook/…' + end + + def event_field(event) + end + + def default_channel_placeholder + end + + def default_fields + [ + { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" }, + { type: 'checkbox', name: 'notify_only_broken_pipelines' }, + { type: 'checkbox', name: 'notify_only_default_branch' }, + ] + end + + private + + def notify(message, opts) + MicrosoftTeams::Notifier.new(webhook).ping( + title: message.project_name, + pretext: message.pretext, + activity: message.activity, + attachments: message.attachments + ) + end + + def custom_data(data) + super(data).merge(markdown: true) + end +end diff --git a/app/models/repository.rb b/app/models/repository.rb index dc1c1fab880..1293cb1d486 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -6,6 +6,8 @@ class Repository attr_accessor :path_with_namespace, :project + delegate :ref_name_for_sha, to: :raw_repository + CommitError = Class.new(StandardError) CreateTreeError = Class.new(StandardError) @@ -700,14 +702,6 @@ class Repository end end - def ref_name_for_sha(ref_path, sha) - args = %W(#{Gitlab.config.git.bin_path} for-each-ref --count=1 #{ref_path} --contains #{sha}) - - # Not found -> ["", 0] - # Found -> ["b8d95eb4969eefacb0a58f6a28f6803f8070e7b9 commit\trefs/environments/production/77\n", 0] - Gitlab::Popen.popen(args, path_to_repo).first.split.last - end - def refs_contains_sha(ref_type, sha) args = %W(#{Gitlab.config.git.bin_path} #{ref_type} --contains #{sha}) names = Gitlab::Popen.popen(args, path_to_repo).first diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb index f4bcb49b34d..bfaf0eb2fae 100644 --- a/app/models/sent_notification.rb +++ b/app/models/sent_notification.rb @@ -5,10 +5,11 @@ class SentNotification < ActiveRecord::Base belongs_to :noteable, polymorphic: true belongs_to :recipient, class_name: "User" - validates :project, :recipient, :reply_key, presence: true - validates :reply_key, uniqueness: true + validates :project, :recipient, presence: true + validates :reply_key, presence: true, uniqueness: true validates :noteable_id, presence: true, unless: :for_commit? validates :commit_id, presence: true, if: :for_commit? + validates :in_reply_to_discussion_id, format: { with: /\A\h{40}\z/, allow_nil: true } validate :note_valid after_save :keep_around_commit @@ -22,9 +23,7 @@ class SentNotification < ActiveRecord::Base find_by(reply_key: reply_key) end - def record(noteable, recipient_id, reply_key, attrs = {}) - return unless reply_key - + def record(noteable, recipient_id, reply_key = self.reply_key, attrs = {}) noteable_id = nil commit_id = nil if noteable.is_a?(Commit) @@ -34,23 +33,20 @@ class SentNotification < ActiveRecord::Base end attrs.reverse_merge!( - project: noteable.project, - noteable_type: noteable.class.name, - noteable_id: noteable_id, - commit_id: commit_id, - recipient_id: recipient_id, - reply_key: reply_key + project: noteable.project, + recipient_id: recipient_id, + reply_key: reply_key, + + noteable_type: noteable.class.name, + noteable_id: noteable_id, + commit_id: commit_id, ) create(attrs) end - def record_note(note, recipient_id, reply_key, attrs = {}) - if note.diff_note? - attrs[:note_type] = note.type - - attrs.merge!(note.diff_attributes) - end + def record_note(note, recipient_id, reply_key = self.reply_key, attrs = {}) + attrs[:in_reply_to_discussion_id] = note.discussion_id record(note.noteable, recipient_id, reply_key, attrs) end @@ -89,31 +85,45 @@ class SentNotification < ActiveRecord::Base self.reply_key end - def note_attributes - { - project: self.project, - author: self.recipient, - type: self.note_type, - noteable_type: self.noteable_type, - noteable_id: self.noteable_id, - commit_id: self.commit_id, - line_code: self.line_code, - position: self.position.to_json - } - end - - def create_note(note) - Notes::CreateService.new( - self.project, - self.recipient, - self.note_attributes.merge(note: note) - ).execute + def create_reply(message, dryrun: false) + klass = dryrun ? Notes::BuildService : Notes::CreateService + klass.new(self.project, self.recipient, reply_params.merge(note: message)).execute end private + def reply_params + attrs = { + noteable_type: self.noteable_type, + noteable_id: self.noteable_id, + commit_id: self.commit_id + } + + if self.in_reply_to_discussion_id.present? + attrs[:in_reply_to_discussion_id] = self.in_reply_to_discussion_id + else + # Remove in GitLab 10.0, when we will not support replying to SentNotifications + # that don't have `in_reply_to_discussion_id` anymore. + attrs.merge!( + type: self.note_type, + + # LegacyDiffNote + line_code: self.line_code, + + # DiffNote + position: self.position.to_json + ) + end + + attrs + end + def note_valid - Note.new(note_attributes.merge(note: "Test")).valid? + note = create_reply('Test', dryrun: true) + + unless note.valid? + self.errors.add(:base, "Note parameters are invalid: #{note.errors.full_messages.to_sentence}") + end end def keep_around_commit diff --git a/app/models/service.rb b/app/models/service.rb index 5a0ec58d193..dc76bf925d3 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -237,6 +237,7 @@ class Service < ActiveRecord::Base slack_slash_commands slack teamcity + microsoft_teams ] if Rails.env.development? service_names += %w[mock_ci mock_deployment mock_monitoring] diff --git a/app/models/snippet.rb b/app/models/snippet.rb index 30aca62499c..380835707e8 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -2,6 +2,7 @@ class Snippet < ActiveRecord::Base include Gitlab::VisibilityLevel include Linguist::BlobHelper include CacheMarkdownField + include Noteable include Participable include Referable include Sortable diff --git a/app/models/user.rb b/app/models/user.rb index 95a766f2ede..87eeee204f8 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -89,7 +89,8 @@ class User < ActiveRecord::Base has_many :subscriptions, dependent: :destroy has_many :recent_events, -> { order "id DESC" }, foreign_key: :author_id, class_name: "Event" has_many :oauth_applications, class_name: 'Doorkeeper::Application', as: :owner, dependent: :destroy - has_one :abuse_report, dependent: :destroy + has_one :abuse_report, dependent: :destroy, foreign_key: :user_id + has_many :reported_abuse_reports, dependent: :destroy, foreign_key: :reporter_id, class_name: "AbuseReport" has_many :spam_logs, dependent: :destroy has_many :builds, dependent: :nullify, class_name: 'Ci::Build' has_many :pipelines, dependent: :nullify, class_name: 'Ci::Pipeline' @@ -484,6 +485,14 @@ class User < ActiveRecord::Base Group.member_descendants(id) end + def all_expanded_groups + Group.member_hierarchy(id) + end + + def expanded_groups_requiring_two_factor_authentication + all_expanded_groups.where(require_two_factor_authentication: true) + end + def nested_groups_projects Project.joins(:namespace).where('namespaces.parent_id IS NOT NULL'). member_descendants(id) @@ -955,6 +964,15 @@ class User < ActiveRecord::Base self.admin = (new_level == 'admin') end + def update_two_factor_requirement + periods = expanded_groups_requiring_two_factor_authentication.pluck(:two_factor_grace_period) + + self.require_two_factor_authentication_from_group = periods.any? + self.two_factor_grace_period = periods.min || User.column_defaults['two_factor_grace_period'] + + save + end + protected # override, from Devise::Validatable diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb index db82b8f6c30..5e151b0f044 100644 --- a/app/services/auth/container_registry_authentication_service.rb +++ b/app/services/auth/container_registry_authentication_service.rb @@ -17,6 +17,7 @@ module Auth end def self.full_access_token(*names) + names = names.flatten registry = Gitlab.config.registry token = JSONWebToken::RSAToken.new(registry.key) token.issuer = registry.issuer @@ -37,13 +38,13 @@ module Auth private def authorized_token(*accesses) - token = JSONWebToken::RSAToken.new(registry.key) - token.issuer = registry.issuer - token.audience = params[:service] - token.subject = current_user.try(:username) - token.expire_time = self.class.token_expire_at - token[:access] = accesses.compact - token + JSONWebToken::RSAToken.new(registry.key).tap do |token| + token.issuer = registry.issuer + token.audience = params[:service] + token.subject = current_user.try(:username) + token.expire_time = self.class.token_expire_at + token[:access] = accesses.compact + end end def scope @@ -55,20 +56,43 @@ module Auth def process_scope(scope) type, name, actions = scope.split(':', 3) actions = actions.split(',') + path = ContainerRegistry::Path.new(name) + return unless type == 'repository' - process_repository_access(type, name, actions) + process_repository_access(type, path, actions) end - def process_repository_access(type, name, actions) - requested_project = Project.find_by_full_path(name) + def process_repository_access(type, path, actions) + return unless path.valid? + + requested_project = path.repository_project + return unless requested_project actions = actions.select do |action| can_access?(requested_project, action) end - { type: type, name: name, actions: actions } if actions.present? + return unless actions.present? + + # At this point user/build is already authenticated. + # + ensure_container_repository!(path, actions) + + { type: type, name: path.to_s, actions: actions } + end + + ## + # Because we do not have two way communication with registry yet, + # we create a container repository image resource when push to the + # registry is successfuly authorized. + # + def ensure_container_repository!(path, actions) + return if path.has_repository? + return unless actions.include?('push') + + ContainerRepository.create_from_path!(path) end def can_access?(requested_project, requested_action) @@ -101,6 +125,11 @@ module Auth can?(current_user, :read_container_image, requested_project) end + ## + # We still support legacy pipeline triggers which do not have associated + # actor. New permissions model and new triggers are always associated with + # an actor, so this should be improved in 10.0 version of GitLab. + # def build_can_push?(requested_project) # Build can push only to the project from which it originates has_authentication_ability?(:build_create_container_image) && @@ -113,14 +142,11 @@ module Auth end def error(code, status:, message: '') - { - errors: [{ code: code, message: message }], - http_status: status - } + { errors: [{ code: code, message: message }], http_status: status } end def has_authentication_ability?(capability) - (@authentication_abilities || []).include?(capability) + @authentication_abilities.to_a.include?(capability) end end end diff --git a/app/services/concerns/issues/resolve_discussions.rb b/app/services/concerns/issues/resolve_discussions.rb index 297c7d696c3..910a2a15e5d 100644 --- a/app/services/concerns/issues/resolve_discussions.rb +++ b/app/services/concerns/issues/resolve_discussions.rb @@ -21,11 +21,11 @@ module Issues @discussions_to_resolve ||= if discussion_to_resolve_id discussion_or_nil = merge_request_to_resolve_discussions_of - .find_diff_discussion(discussion_to_resolve_id) + .find_discussion(discussion_to_resolve_id) Array(discussion_or_nil) else merge_request_to_resolve_discussions_of - .resolvable_discussions + .discussions_to_be_resolved end end end diff --git a/app/services/issues/build_service.rb b/app/services/issues/build_service.rb index 77bced4bd5c..3a4f7b159f1 100644 --- a/app/services/issues/build_service.rb +++ b/app/services/issues/build_service.rb @@ -35,14 +35,19 @@ module Issues end def item_for_discussion(discussion) - first_note = discussion.first_note_to_resolve || discussion.first_note + first_note_to_resolve = discussion.first_note_to_resolve || discussion.first_note + + is_very_first_note = first_note_to_resolve == discussion.first_note + action = is_very_first_note ? "started" : "commented on" + + note_url = Gitlab::UrlBuilder.build(first_note_to_resolve) + other_note_count = discussion.notes.size - 1 - note_url = Gitlab::UrlBuilder.build(first_note) - discussion_info = "- [ ] #{first_note.author.to_reference} commented on a [discussion](#{note_url}): " + discussion_info = "- [ ] #{first_note_to_resolve.author.to_reference} #{action} a [discussion](#{note_url}): " discussion_info << " (+#{other_note_count} #{'comment'.pluralize(other_note_count)})" if other_note_count > 0 - note_without_block_quotes = Banzai::Filter::BlockquoteFenceFilter.new(first_note.note).call + note_without_block_quotes = Banzai::Filter::BlockquoteFenceFilter.new(first_note_to_resolve.note).call spaces = ' ' * 4 quote = note_without_block_quotes.lines.map { |line| "#{spaces}> #{line}" }.join diff --git a/app/services/notes/build_service.rb b/app/services/notes/build_service.rb new file mode 100644 index 00000000000..ea7cacc956c --- /dev/null +++ b/app/services/notes/build_service.rb @@ -0,0 +1,25 @@ +module Notes + class BuildService < ::BaseService + def execute + in_reply_to_discussion_id = params.delete(:in_reply_to_discussion_id) + + if project && in_reply_to_discussion_id.present? + discussion = project.notes.find_discussion(in_reply_to_discussion_id) + + unless discussion + note = Note.new + note.errors.add(:base, 'Discussion to reply to cannot be found') + return note + end + + params.merge!(discussion.reply_attributes) + end + + note = Note.new(params) + note.project = project + note.author = current_user + + note + end + end +end diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index 61d66a26932..f3954f6f8c4 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -1,12 +1,10 @@ module Notes - class CreateService < BaseService + class CreateService < ::BaseService def execute merge_request_diff_head_sha = params.delete(:merge_request_diff_head_sha) - note = Note.new(params) - note.project = project - note.author = current_user - note.system = false + note = Notes::BuildService.new(project, current_user, params).execute + return note unless note.valid? # We execute commands (extracted from `params[:note]`) on the noteable # **before** we save the note because if the note consists of commands diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index a7142d5950e..06d8d143231 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -31,16 +31,16 @@ module Projects project.team.truncate project.destroy! - unless remove_registry_tags - raise_error('Failed to remove project container registry. Please try again or contact administrator') + unless remove_legacy_registry_tags + raise_error('Failed to remove some tags in project container registry. Please try again or contact administrator.') end unless remove_repository(repo_path) - raise_error('Failed to remove project repository. Please try again or contact administrator') + raise_error('Failed to remove project repository. Please try again or contact administrator.') end unless remove_repository(wiki_path) - raise_error('Failed to remove wiki repository. Please try again or contact administrator') + raise_error('Failed to remove wiki repository. Please try again or contact administrator.') end end @@ -68,10 +68,16 @@ module Projects end end - def remove_registry_tags + ## + # This method makes sure that we correctly remove registry tags + # for legacy image repository (when repository path equals project path). + # + def remove_legacy_registry_tags return true unless Gitlab.config.registry.enabled - project.container_registry_repository.delete_tags + ContainerRepository.build_root_repository(project).tap do |repository| + return repository.has_tags? ? repository.delete_tags! : true + end end def raise_error(message) diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 35cfcc3682e..c9e25c7aaa2 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -228,12 +228,10 @@ module SystemNoteService def discussion_continued_in_issue(discussion, project, author, issue) body = "created #{issue.to_reference} to continue this discussion" + note_attributes = discussion.reply_attributes.merge(project: project, author: author, note: body) - note_params = discussion.reply_attributes.merge(project: project, author: author, note: body) - note_params[:type] = note_params.delete(:note_type) - - note = Note.create(note_params.merge(system: true)) - note.system_note_metadata = SystemNoteMetadata.new({ action: 'discussion' }) + note = Note.create(note_attributes.merge(system: true)) + note.system_note_metadata = SystemNoteMetadata.new(action: 'discussion') note end diff --git a/app/services/users/destroy_service.rb b/app/services/users/destroy_service.rb index a3b32a71a64..ba58b174cc0 100644 --- a/app/services/users/destroy_service.rb +++ b/app/services/users/destroy_service.rb @@ -26,7 +26,7 @@ module Users ::Projects::DestroyService.new(project, current_user, skip_repo: true).execute end - move_issues_to_ghost_user(user) + MigrateToGhostUserService.new(user).execute # Destroy the namespace after destroying the user since certain methods may depend on the namespace existing namespace = user.namespace @@ -35,22 +35,5 @@ module Users user_data end - - private - - def move_issues_to_ghost_user(user) - # Block the user before moving issues to prevent a data race. - # If the user creates an issue after `move_issues_to_ghost_user` - # runs and before the user is destroyed, the destroy will fail with - # an exception. We block the user so that issues can't be created - # after `move_issues_to_ghost_user` runs and before the destroy happens. - user.block - - ghost_user = User.ghost - - user.issues.update_all(author_id: ghost_user.id) - - user.reload - end end end diff --git a/app/services/users/migrate_to_ghost_user_service.rb b/app/services/users/migrate_to_ghost_user_service.rb new file mode 100644 index 00000000000..1e1ed1791ec --- /dev/null +++ b/app/services/users/migrate_to_ghost_user_service.rb @@ -0,0 +1,59 @@ +# When a user is destroyed, some of their associated records are +# moved to a "Ghost User", to prevent these associated records from +# being destroyed. +# +# For example, all the issues/MRs a user has created are _not_ destroyed +# when the user is destroyed. +module Users + class MigrateToGhostUserService + extend ActiveSupport::Concern + + attr_reader :ghost_user, :user + + def initialize(user) + @user = user + end + + def execute + # Block the user before moving records to prevent a data race. + # For example, if the user creates an issue after `migrate_issues` + # runs and before the user is destroyed, the destroy will fail with + # an exception. + user.block + + user.transaction do + @ghost_user = User.ghost + + migrate_issues + migrate_merge_requests + migrate_notes + migrate_abuse_reports + migrate_award_emoji + end + + user.reload + end + + private + + def migrate_issues + user.issues.update_all(author_id: ghost_user.id) + end + + def migrate_merge_requests + user.merge_requests.update_all(author_id: ghost_user.id) + end + + def migrate_notes + user.notes.update_all(author_id: ghost_user.id) + end + + def migrate_abuse_reports + user.reported_abuse_reports.update_all(reporter_id: ghost_user.id) + end + + def migrate_award_emoji + user.award_emoji.update_all(user_id: ghost_user.id) + end + end +end diff --git a/app/validators/cron_timezone_validator.rb b/app/validators/cron_timezone_validator.rb new file mode 100644 index 00000000000..542c7d006ad --- /dev/null +++ b/app/validators/cron_timezone_validator.rb @@ -0,0 +1,9 @@ +# CronTimezoneValidator +# +# Custom validator for CronTimezone. +class CronTimezoneValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + cron_parser = Gitlab::Ci::CronParser.new(record.cron, record.cron_timezone) + record.errors.add(attribute, " is invalid syntax") unless cron_parser.cron_timezone_valid? + end +end diff --git a/app/validators/cron_validator.rb b/app/validators/cron_validator.rb new file mode 100644 index 00000000000..981fade47a6 --- /dev/null +++ b/app/validators/cron_validator.rb @@ -0,0 +1,9 @@ +# CronValidator +# +# Custom validator for Cron. +class CronValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + cron_parser = Gitlab::Ci::CronParser.new(record.cron, record.cron_timezone) + record.errors.add(attribute, " is invalid syntax") unless cron_parser.cron_valid? + end +end diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml index 589f4557b52..d9f05003904 100644 --- a/app/views/admin/groups/_form.html.haml +++ b/app/views/admin/groups/_form.html.haml @@ -13,7 +13,7 @@ .col-sm-offset-2.col-sm-10 = render 'shared/allow_request_access', form: f - = render 'groups/group_lfs_settings', f: f + = render 'groups/group_admin_settings', f: f - if @group.new_record? .form-group diff --git a/app/views/award_emoji/_awards_block.html.haml b/app/views/award_emoji/_awards_block.html.haml index 5aae410a63f..3ca45fbf751 100644 --- a/app/views/award_emoji/_awards_block.html.haml +++ b/app/views/award_emoji/_awards_block.html.haml @@ -13,5 +13,7 @@ %button.btn.award-control.has-tooltip.js-add-award{ type: 'button', 'aria-label': 'Add emoji', data: { title: 'Add emoji', placement: "bottom" } } - = icon('smile-o', class: "award-control-icon award-control-icon-normal") + %span{ class: "award-control-icon award-control-icon-neutral" }= custom_icon('emoji_slightly_smiling_face') + %span{ class: "award-control-icon award-control-icon-positive" }= custom_icon('emoji_smiley') + %span{ class: "award-control-icon award-control-icon-super-positive" }= custom_icon('emoji_smile') = icon('spinner spin', class: "award-control-icon award-control-icon-loading") diff --git a/app/views/discussions/_diff_discussion.html.haml b/app/views/discussions/_diff_discussion.html.haml index ee452add394..e6d307e5568 100644 --- a/app/views/discussions/_diff_discussion.html.haml +++ b/app/views/discussions/_diff_discussion.html.haml @@ -3,4 +3,4 @@ %td.notes_line{ colspan: 2 } %td.notes_content .content{ class: ('hide' unless expanded) } - = render "discussions/notes", discussion: discussion + = render partial: "discussions/notes", collection: discussions, as: :discussion diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml index 94408b92374..549364761e6 100644 --- a/app/views/discussions/_diff_with_notes.html.haml +++ b/app/views/discussions/_diff_with_notes.html.haml @@ -7,7 +7,7 @@ .diff-content.code.js-syntax-highlight %table - - discussions = { discussion.original_line_code => discussion } + - discussions = { discussion.original_line_code => [discussion] } = render partial: "projects/diffs/line", collection: discussion.truncated_diff_lines, as: :line, diff --git a/app/views/discussions/_discussion.html.haml b/app/views/discussions/_discussion.html.haml index 2d78c55211e..e04958817e4 100644 --- a/app/views/discussions/_discussion.html.haml +++ b/app/views/discussions/_discussion.html.haml @@ -5,7 +5,7 @@ = link_to user_path(discussion.author) do = image_tag avatar_icon(discussion.author), class: "avatar s40" .timeline-content - .discussion.js-toggle-container{ class: discussion.id, data: { discussion_id: discussion.id } } + .discussion.js-toggle-container{ data: { discussion_id: discussion.id } } .discussion-header .discussion-actions %button.note-action-button.discussion-toggle-button.js-toggle-button{ type: "button" } @@ -18,19 +18,21 @@ .inline.discussion-headline-light = discussion.author.to_reference - started a discussion on + started a discussion - - if discussion.for_commit? + - if discussion.for_commit? && @noteable != discussion.noteable + on - commit = discussion.noteable - if commit commit - = link_to commit.short_id, namespace_project_commit_path(discussion.project.namespace, discussion.project, discussion.noteable, anchor: discussion.line_code), class: 'monospace' + - anchor = discussion.line_code if discussion.diff_discussion? + = link_to commit.short_id, namespace_project_commit_path(discussion.project.namespace, discussion.project, discussion.noteable, anchor: anchor), class: 'monospace' - else a deleted commit - - else + - elsif discussion.diff_discussion? + on - if discussion.active? - = link_to diffs_namespace_project_merge_request_path(discussion.project.namespace, discussion.project, discussion.noteable, anchor: discussion.line_code) do - the diff + = link_to 'the diff', discussion_diff_path(discussion) - else an outdated diff diff --git a/app/views/discussions/_notes.html.haml b/app/views/discussions/_notes.html.haml index 2789391819c..34789808f10 100644 --- a/app/views/discussions/_notes.html.haml +++ b/app/views/discussions/_notes.html.haml @@ -1,18 +1,20 @@ -%ul.notes{ data: { discussion_id: discussion.id } } - = render partial: "projects/notes/note", collection: discussion.notes, as: :note +.discussion-notes + %ul.notes{ data: { discussion_id: discussion.id } } + = render partial: "projects/notes/note", collection: discussion.notes, as: :note -- if current_user - .discussion-reply-holder - - if discussion.diff_discussion? - - line_type = local_assigns.fetch(:line_type, nil) + - if current_user + .discussion-reply-holder + - if discussion.potentially_resolvable? + - line_type = local_assigns.fetch(:line_type, nil) + + .btn-group-justified.discussion-with-resolve-btn{ role: "group" } + .btn-group{ role: "group" } + = link_to_reply_discussion(discussion, line_type) + + = render "discussions/resolve_all", discussion: discussion - .btn-group-justified.discussion-with-resolve-btn{ role: "group" } - .btn-group{ role: "group" } - = link_to_reply_discussion(discussion, line_type) - = render "discussions/resolve_all", discussion: discussion - - if discussion.for_merge_request? .btn-group.discussion-actions = render "discussions/new_issue_for_discussion", discussion: discussion, merge_request: discussion.noteable = render "discussions/jump_to_next", discussion: discussion - - else - = link_to_reply_discussion(discussion) + - else + = link_to_reply_discussion(discussion) diff --git a/app/views/discussions/_parallel_diff_discussion.html.haml b/app/views/discussions/_parallel_diff_discussion.html.haml index 3a19e021643..253cd336882 100644 --- a/app/views/discussions/_parallel_diff_discussion.html.haml +++ b/app/views/discussions/_parallel_diff_discussion.html.haml @@ -1,20 +1,20 @@ -- expanded = discussion_left.try(:expanded?) || discussion_right.try(:expanded?) +- expanded = [*discussions_left, *discussions_right].any?(&:expanded?) %tr.notes_holder{ class: ('hide' unless expanded) } - - if discussion_left + - if discussions_left %td.notes_line.old %td.notes_content.parallel.old - .content{ class: ('hide' unless discussion_left.expanded?) } - = render "discussions/notes", discussion: discussion_left, line_type: 'old' + .content{ class: ('hide' unless discussions_left.any?(&:expanded?)) } + = render partial: "discussions/notes", collection: discussions_left, as: :discussion, line_type: 'old' - else %td.notes_line.old= ("") %td.notes_content.parallel.old .content - - if discussion_right + - if discussions_right %td.notes_line.new %td.notes_content.parallel.new - .content{ class: ('hide' unless discussion_right.expanded?) } - = render "discussions/notes", discussion: discussion_right, line_type: 'new' + .content{ class: ('hide' unless discussions_right.any?(&:expanded?)) } + = render partial: "discussions/notes", collection: discussions_right, as: :discussion, line_type: 'new' - else %td.notes_line.new= ("") %td.notes_content.parallel.new diff --git a/app/views/discussions/_resolve_all.html.haml b/app/views/discussions/_resolve_all.html.haml index e30ee1b0e05..689a22acd27 100644 --- a/app/views/discussions/_resolve_all.html.haml +++ b/app/views/discussions/_resolve_all.html.haml @@ -1,9 +1,8 @@ -- if discussion.for_merge_request? - %resolve-discussion-btn{ ":discussion-id" => "'#{discussion.id}'", - ":merge-request-id" => discussion.noteable.iid, - ":can-resolve" => discussion.can_resolve?(current_user), - "inline-template" => true } - .btn-group{ role: "group", "v-if" => "showButton" } - %button.btn.btn-default{ type: "button", "@click" => "resolve", ":disabled" => "loading", "v-cloak" => "true" } - = icon("spinner spin", "v-show" => "loading") - {{ buttonText }} +%resolve-discussion-btn{ ":discussion-id" => "'#{discussion.id}'", + ":merge-request-id" => discussion.noteable.iid, + ":can-resolve" => discussion.can_resolve?(current_user), + "inline-template" => true } + .btn-group{ role: "group", "v-if" => "showButton" } + %button.btn.btn-default{ type: "button", "@click" => "resolve", ":disabled" => "loading", "v-cloak" => "true" } + = icon("spinner spin", "v-show" => "loading") + {{ buttonText }} diff --git a/app/views/events/_event.html.haml b/app/views/events/_event.html.haml index a0bd14df209..53a33adc14d 100644 --- a/app/views/events/_event.html.haml +++ b/app/views/events/_event.html.haml @@ -3,8 +3,6 @@ .event-item-timestamp #{time_ago_with_tooltip(event.created_at)} - = author_avatar(event, size: 40) - - if event.created_project? = render "events/event/created_project", event: event - elsif event.push? diff --git a/app/views/events/event/_common.html.haml b/app/views/events/event/_common.html.haml index 2fb6b5647da..2a98e58a03a 100644 --- a/app/views/events/event/_common.html.haml +++ b/app/views/events/event/_common.html.haml @@ -1,5 +1,15 @@ +- if event.target + - if event.action_name == "opened" + .profile-icon.open-icon + = custom_icon("icon_status_open") + - elsif event.action_name == "closed" + .profile-icon.closed-icon + = custom_icon("icon_status_closed") + - else + .profile-icon.fork-icon + = custom_icon("code_fork") + .event-title - %span.author_name= link_to_author event %span{ class: event.action_name } - if event.target = event.action_name diff --git a/app/views/events/event/_created_project.html.haml b/app/views/events/event/_created_project.html.haml index 80cf2344fe1..340d8c61026 100644 --- a/app/views/events/event/_created_project.html.haml +++ b/app/views/events/event/_created_project.html.haml @@ -1,5 +1,7 @@ +.profile-icon.open-icon + = custom_icon("icon_status_open") + .event-title - %span.author_name= link_to_author event %span{ class: event.action_name } = event_action_name(event) diff --git a/app/views/events/event/_note.html.haml b/app/views/events/event/_note.html.haml index 64b5a733b77..603bed6d705 100644 --- a/app/views/events/event/_note.html.haml +++ b/app/views/events/event/_note.html.haml @@ -1,5 +1,7 @@ +.profile-icon + = custom_icon("comment_o") + .event-title - %span.author_name= link_to_author event = event.action_name = event_note_title_html(event) diff --git a/app/views/events/event/_push.html.haml b/app/views/events/event/_push.html.haml index efd13aabf20..1583f380737 100644 --- a/app/views/events/event/_push.html.haml +++ b/app/views/events/event/_push.html.haml @@ -1,7 +1,12 @@ - project = event.project +.profile-icon + - if event.action_name == "deleted" + = custom_icon("trash_o") + - else + = custom_icon("icon_commit") + .event-title - %span.author_name= link_to_author event %span.pushed #{event.action_name} #{event.ref_type} %strong - commits_link = namespace_project_commits_path(project.namespace, project, event.ref_name) @@ -48,4 +53,3 @@ .event-body %ul.well-list.event_commits = render "events/commit", commit: last_commit, project: project, event: event - diff --git a/app/views/groups/_group_admin_settings.html.haml b/app/views/groups/_group_admin_settings.html.haml new file mode 100644 index 00000000000..2ace1e2dd1e --- /dev/null +++ b/app/views/groups/_group_admin_settings.html.haml @@ -0,0 +1,28 @@ +- if current_user.admin? + .form-group + = f.label :lfs_enabled, 'Large File Storage', class: 'control-label' + .col-sm-10 + .checkbox + = f.label :lfs_enabled do + = f.check_box :lfs_enabled, checked: @group.lfs_enabled? + %strong + Allow projects within this group to use Git LFS + = link_to icon('question-circle'), help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs') + %br/ + %span.descr This setting can be overridden in each project. + +- if can? current_user, :admin_group, @group + .form-group + = f.label :require_two_factor_authentication, 'Two-factor authentication', class: 'control-label col-sm-2' + .col-sm-10 + .checkbox + = f.label :require_two_factor_authentication do + = f.check_box :require_two_factor_authentication + %strong + Require all users in this group to setup Two-factor authentication + = link_to icon('question-circle'), help_page_path('security/two_factor_authentication', anchor: 'enforcing-2fa-for-all-users-in-a-group') + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.text_field :two_factor_grace_period, class: 'form-control' + .help-block Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication diff --git a/app/views/groups/_group_lfs_settings.html.haml b/app/views/groups/_group_lfs_settings.html.haml deleted file mode 100644 index 3c622ca5c3c..00000000000 --- a/app/views/groups/_group_lfs_settings.html.haml +++ /dev/null @@ -1,11 +0,0 @@ -- if current_user.admin? - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :lfs_enabled do - = f.check_box :lfs_enabled, checked: @group.lfs_enabled? - %strong - Allow projects within this group to use Git LFS - = link_to icon('question-circle'), help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs') - %br/ - %span.descr This setting can be overridden in each project. diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml index 80a77dab97f..00ff40224ba 100644 --- a/app/views/groups/edit.html.haml +++ b/app/views/groups/edit.html.haml @@ -27,7 +27,7 @@ .col-sm-offset-2.col-sm-10 = render 'shared/allow_request_access', form: f - = render 'group_lfs_settings', f: f + = render 'group_admin_settings', f: f .form-group %hr diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb new file mode 100644 index 00000000000..198f30a1dc4 --- /dev/null +++ b/app/views/layouts/mailer.text.erb @@ -0,0 +1,4 @@ +<%= yield -%> + +--- +You're receiving this email because of your account on <%= Gitlab.config.gitlab.host %>. diff --git a/app/views/layouts/mailer.text.haml b/app/views/layouts/mailer.text.haml deleted file mode 100644 index 6a9c6ced9cc..00000000000 --- a/app/views/layouts/mailer.text.haml +++ /dev/null @@ -1,5 +0,0 @@ -= yield - -You're receiving this email because of your account on #{Gitlab.config.gitlab.host}. -Manage all notifications: #{profile_notifications_url} -Help: #{help_url} diff --git a/app/views/layouts/notify.html.haml b/app/views/layouts/notify.html.haml index 76268c1b705..40bf45cece7 100644 --- a/app/views/layouts/notify.html.haml +++ b/app/views/layouts/notify.html.haml @@ -25,8 +25,8 @@ - if @labels_url adjust your #{link_to 'label subscriptions', @labels_url}. - else - - if @sent_notification_url - = link_to "unsubscribe", @sent_notification_url + - if @unsubscribe_url + = link_to "unsubscribe", @unsubscribe_url from this thread or adjust your notification settings. diff --git a/app/views/layouts/notify.text.erb b/app/views/layouts/notify.text.erb new file mode 100644 index 00000000000..b4ce02eead8 --- /dev/null +++ b/app/views/layouts/notify.text.erb @@ -0,0 +1,12 @@ +<%= yield -%> + +--- +<% if @target_url -%> +<% if @reply_by_email -%> +<%= "Reply to this email directly or view it on GitLab: #{@target_url}" -%> +<% else -%> +<%= "View it on GitLab: #{@target_url}" -%> +<% end -%> +<% end -%> + +You're receiving this email because of your account on <%= Gitlab.config.gitlab.host %>. diff --git a/app/views/notify/_note_email.html.haml b/app/views/notify/_note_email.html.haml new file mode 100644 index 00000000000..a80518f7986 --- /dev/null +++ b/app/views/notify/_note_email.html.haml @@ -0,0 +1,37 @@ +- discussion = @note.discussion if @note.part_of_discussion? +- if discussion + %p.details + = succeed ':' do + = link_to @note.author_name, user_url(@note.author) + + - if discussion.diff_discussion? + - if discussion.new_discussion? + started a new discussion + - else + commented on a discussion + + on #{link_to discussion.file_path, @target_url} + - else + - if discussion.new_discussion? + started a new discussion + - else + commented on a #{link_to 'discussion', @target_url} + +- elsif current_application_settings.email_author_in_body + %p.details + #{link_to @note.author_name, user_url(@note.author)} commented: + +- if discussion&.diff_discussion? + = content_for :head do + = stylesheet_link_tag 'mailers/highlighted_diff_email' + + %table + = render partial: "projects/diffs/line", + collection: discussion.truncated_diff_lines, + as: :line, + locals: { diff_file: discussion.diff_file, + plain: true, + email: true } + +%div + = markdown(@note.note, pipeline: :email, author: @note.author) diff --git a/app/views/notify/_note_email.text.erb b/app/views/notify/_note_email.text.erb new file mode 100644 index 00000000000..cb2e7fab6d5 --- /dev/null +++ b/app/views/notify/_note_email.text.erb @@ -0,0 +1,26 @@ +<% discussion = @note.discussion if @note.part_of_discussion? -%> +<% if discussion && !discussion.individual_note? -%> +<%= @note.author_name -%> +<% if discussion.new_discussion? -%> +<%= " started a new discussion" -%> +<% else -%> +<%= " commented on a discussion" -%> +<% end -%> +<% if discussion.diff_discussion? -%> +<%= " on #{discussion.file_path}" -%> +<% end -%> +<%= ":" -%> + + +<% elsif current_application_settings.email_author_in_body -%> +<%= "#{@note.author_name} commented:" -%> + + +<% end -%> +<% if discussion&.diff_discussion? -%> +<% discussion.truncated_diff_lines(highlight: false).each do |line| -%> +<%= "> #{line.text}\n" -%> +<% end -%> + +<% end -%> +<%= @note.note -%> diff --git a/app/views/notify/_note_message.html.haml b/app/views/notify/_note_message.html.haml deleted file mode 100644 index e9c66170877..00000000000 --- a/app/views/notify/_note_message.html.haml +++ /dev/null @@ -1,5 +0,0 @@ -- if current_application_settings.email_author_in_body - %div - #{link_to @note.author_name, user_url(@note.author)} wrote: -%div - = markdown(@note.note, pipeline: :email, author: @note.author) diff --git a/app/views/notify/_note_message.text.erb b/app/views/notify/_note_message.text.erb deleted file mode 100644 index f82cbc9a3fc..00000000000 --- a/app/views/notify/_note_message.text.erb +++ /dev/null @@ -1,5 +0,0 @@ -<% if current_application_settings.email_author_in_body %> - <%= @note.author_name %> wrote: -<% end -%> - -<%= @note.note %> diff --git a/app/views/notify/_note_mr_or_commit_email.html.haml b/app/views/notify/_note_mr_or_commit_email.html.haml deleted file mode 100644 index edf8dfe7e9e..00000000000 --- a/app/views/notify/_note_mr_or_commit_email.html.haml +++ /dev/null @@ -1,18 +0,0 @@ -= content_for :head do - = stylesheet_link_tag 'mailers/highlighted_diff_email' - -New comment - -- if @discussion && @discussion.diff_file - on - = link_to @note.diff_file.file_path, @target_url, class: 'details' - \: - %table - = render partial: "projects/diffs/line", - collection: @discussion.truncated_diff_lines, - as: :line, - locals: { diff_file: @note.diff_file, - plain: true, - email: true } - -= render 'note_message' diff --git a/app/views/notify/_note_mr_or_commit_email.text.erb b/app/views/notify/_note_mr_or_commit_email.text.erb deleted file mode 100644 index b4fcdf6b1e9..00000000000 --- a/app/views/notify/_note_mr_or_commit_email.text.erb +++ /dev/null @@ -1,8 +0,0 @@ -<% if @discussion && @discussion.diff_file -%> - on <%= @note.diff_file.file_path -%> -<% end -%>: - -<%= url %> - -<%= render 'simple_diff' if @discussion -%> -<%= render 'note_message' %> diff --git a/app/views/notify/_simple_diff.text.erb b/app/views/notify/_simple_diff.text.erb deleted file mode 100644 index c28d1cc34d3..00000000000 --- a/app/views/notify/_simple_diff.text.erb +++ /dev/null @@ -1,3 +0,0 @@ -<% @discussion.truncated_diff_lines(highlight: false).each do |line| %> -> <%= line.text %> -<% end %> diff --git a/app/views/notify/new_issue_email.html.haml b/app/views/notify/new_issue_email.html.haml index d1855568215..c762578971a 100644 --- a/app/views/notify/new_issue_email.html.haml +++ b/app/views/notify/new_issue_email.html.haml @@ -1,9 +1,11 @@ - if current_application_settings.email_author_in_body - %div - #{link_to @issue.author_name, user_url(@issue.author)} wrote: -- if @issue.description - = markdown(@issue.description, pipeline: :email, author: @issue.author) + %p.details + #{link_to @issue.author_name, user_url(@issue.author)} created an issue: - if @issue.assignee_id.present? %p Assignee: #{@issue.assignee_name} + +- if @issue.description + %div + = markdown(@issue.description, pipeline: :email, author: @issue.author) diff --git a/app/views/notify/new_mention_in_issue_email.html.haml b/app/views/notify/new_mention_in_issue_email.html.haml index 02f21baa368..6b45ac265f7 100644 --- a/app/views/notify/new_mention_in_issue_email.html.haml +++ b/app/views/notify/new_mention_in_issue_email.html.haml @@ -1,12 +1,4 @@ %p You have been mentioned in an issue. -- if current_application_settings.email_author_in_body - %div - #{link_to @issue.author_name, user_url(@issue.author)} wrote: -- if @issue.description - = markdown(@issue.description, pipeline: :email, author: @issue.author) - -- if @issue.assignee_id.present? - %p - Assignee: #{@issue.assignee_name} += render template: 'notify/new_issue_email' diff --git a/app/views/notify/new_mention_in_merge_request_email.html.haml b/app/views/notify/new_mention_in_merge_request_email.html.haml index cbd434be02a..b061f9c106e 100644 --- a/app/views/notify/new_mention_in_merge_request_email.html.haml +++ b/app/views/notify/new_mention_in_merge_request_email.html.haml @@ -1,15 +1,4 @@ %p You have been mentioned in Merge Request #{@merge_request.to_reference} -- if current_application_settings.email_author_in_body - %div - #{link_to @merge_request.author_name, user_url(@merge_request.author)} wrote: -%p.details - != merge_path_description(@merge_request, '→') - -- if @merge_request.assignee_id.present? - %p - Assignee: #{@merge_request.author_name} → #{@merge_request.assignee_name} - -- if @merge_request.description - = markdown(@merge_request.description, pipeline: :email, author: @merge_request.author) += render template: 'notify/new_merge_request_email' diff --git a/app/views/notify/new_merge_request_email.html.haml b/app/views/notify/new_merge_request_email.html.haml index 8890b300f7d..951c96bdb9c 100644 --- a/app/views/notify/new_merge_request_email.html.haml +++ b/app/views/notify/new_merge_request_email.html.haml @@ -1,12 +1,14 @@ - if current_application_settings.email_author_in_body - %div - #{link_to @merge_request.author_name, user_url(@merge_request.author)} wrote: + %p.details + #{link_to @merge_request.author_name, user_url(@merge_request.author)} created a merge request: + %p.details != merge_path_description(@merge_request, '→') - if @merge_request.assignee_id.present? %p - Assignee: #{@merge_request.author_name} → #{@merge_request.assignee_name} + Assignee: #{@merge_request.assignee_name} - if @merge_request.description - = markdown(@merge_request.description, pipeline: :email, author: @merge_request.author) + %div + = markdown(@merge_request.description, pipeline: :email, author: @merge_request.author) diff --git a/app/views/notify/note_commit_email.html.haml b/app/views/notify/note_commit_email.html.haml index 0a650e3b2ca..5e69f01a486 100644 --- a/app/views/notify/note_commit_email.html.haml +++ b/app/views/notify/note_commit_email.html.haml @@ -1,2 +1 @@ -%p.details - = render 'note_mr_or_commit_email' += render 'note_email' diff --git a/app/views/notify/note_commit_email.text.erb b/app/views/notify/note_commit_email.text.erb index 6aa085a172e..413d9e6e9ac 100644 --- a/app/views/notify/note_commit_email.text.erb +++ b/app/views/notify/note_commit_email.text.erb @@ -1,2 +1 @@ -New comment for Commit <%= @commit.short_id -%> -<%= render partial: 'note_mr_or_commit_email', locals: { url: @target_url } %> +<%= render 'note_email' %> diff --git a/app/views/notify/note_issue_email.html.haml b/app/views/notify/note_issue_email.html.haml index 2fa2f784661..5e69f01a486 100644 --- a/app/views/notify/note_issue_email.html.haml +++ b/app/views/notify/note_issue_email.html.haml @@ -1 +1 @@ -= render 'note_message' += render 'note_email' diff --git a/app/views/notify/note_issue_email.text.erb b/app/views/notify/note_issue_email.text.erb index e33cbcd70f2..413d9e6e9ac 100644 --- a/app/views/notify/note_issue_email.text.erb +++ b/app/views/notify/note_issue_email.text.erb @@ -1,9 +1 @@ -New comment for Issue <%= @issue.iid %> - -<%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue, anchor: "note_#{@note.id}")) %> - - -Author: <%= @note.author_name %> - -<%= @note.note %> - +<%= render 'note_email' %> diff --git a/app/views/notify/note_merge_request_email.html.haml b/app/views/notify/note_merge_request_email.html.haml index 0a650e3b2ca..5e69f01a486 100644 --- a/app/views/notify/note_merge_request_email.html.haml +++ b/app/views/notify/note_merge_request_email.html.haml @@ -1,2 +1 @@ -%p.details - = render 'note_mr_or_commit_email' += render 'note_email' diff --git a/app/views/notify/note_merge_request_email.text.erb b/app/views/notify/note_merge_request_email.text.erb index 2ce64c494cf..413d9e6e9ac 100644 --- a/app/views/notify/note_merge_request_email.text.erb +++ b/app/views/notify/note_merge_request_email.text.erb @@ -1,2 +1 @@ -New comment for Merge Request <%= @merge_request.to_reference -%> -<%= render partial: 'note_mr_or_commit_email', locals: { url: @target_url }%> +<%= render 'note_email' %> diff --git a/app/views/notify/note_personal_snippet_email.html.haml b/app/views/notify/note_personal_snippet_email.html.haml index 2fa2f784661..5e69f01a486 100644 --- a/app/views/notify/note_personal_snippet_email.html.haml +++ b/app/views/notify/note_personal_snippet_email.html.haml @@ -1 +1 @@ -= render 'note_message' += render 'note_email' diff --git a/app/views/notify/note_personal_snippet_email.text.erb b/app/views/notify/note_personal_snippet_email.text.erb index b2a8809a23b..413d9e6e9ac 100644 --- a/app/views/notify/note_personal_snippet_email.text.erb +++ b/app/views/notify/note_personal_snippet_email.text.erb @@ -1,8 +1 @@ -New comment for Snippet <%= @snippet.id %> - -<%= url_for(snippet_url(@snippet, anchor: "note_#{@note.id}")) %> - - -Author: <%= @note.author_name %> - -<%= @note.note %> +<%= render 'note_email' %> diff --git a/app/views/notify/note_snippet_email.html.haml b/app/views/notify/note_snippet_email.html.haml index 2fa2f784661..5e69f01a486 100644 --- a/app/views/notify/note_snippet_email.html.haml +++ b/app/views/notify/note_snippet_email.html.haml @@ -1 +1 @@ -= render 'note_message' += render 'note_email' diff --git a/app/views/notify/note_snippet_email.text.erb b/app/views/notify/note_snippet_email.text.erb index 4d5a406f4b0..413d9e6e9ac 100644 --- a/app/views/notify/note_snippet_email.text.erb +++ b/app/views/notify/note_snippet_email.text.erb @@ -1,8 +1 @@ -New comment for Snippet <%= @snippet.id %> - -<%= url_for(namespace_project_snippet_url(@snippet.project.namespace, @snippet.project, @snippet, anchor: "note_#{@note.id}")) %> - - -Author: <%= @note.author_name %> - -<%= @note.note %> +<%= render 'note_email' %> diff --git a/app/views/notify/pipeline_failed_email.html.haml b/app/views/notify/pipeline_failed_email.html.haml index 4beb6fcee5d..a83faa839df 100644 --- a/app/views/notify/pipeline_failed_email.html.haml +++ b/app/views/notify/pipeline_failed_email.html.haml @@ -137,6 +137,6 @@ - if build.has_trace? %td{ colspan: "2", style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:0 0 15px;" } %pre{ style: "font-family:Monaco,'Lucida Console','Courier New',Courier,monospace;background-color:#fafafa;border-radius:3px;overflow:hidden;white-space:pre-wrap;word-break:break-all;font-size:13px;line-height:1.4;padding:12px;color:#333333;margin:0;" } - = build.trace_html(last_lines: 10).html_safe + = build.trace.html(last_lines: 10).html_safe - else %td{ colspan: "2" } diff --git a/app/views/notify/pipeline_failed_email.text.erb b/app/views/notify/pipeline_failed_email.text.erb index c1a4ea40cf5..294238eee51 100644 --- a/app/views/notify/pipeline_failed_email.text.erb +++ b/app/views/notify/pipeline_failed_email.text.erb @@ -35,7 +35,7 @@ had <%= failed.size %> failed <%= 'build'.pluralize(failed.size) %>. Stage: <%= build.stage %> Name: <%= build.name %> <% if build.has_trace? -%> -Trace: <%= build.trace_with_state(last_lines: 10)[:text] %> +Trace: <%= build.trace.raw(last_lines: 10) %> <% end -%> <% end -%> diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml index 2b2ee6ed987..aa9b852035e 100644 --- a/app/views/projects/blob/_blob.html.haml +++ b/app/views/projects/blob/_blob.html.haml @@ -25,4 +25,11 @@ #blob-content-holder.blob-content-holder %article.file-holder = render "projects/blob/header", blob: blob + - if current_user + .js-file-fork-suggestion-section.file-fork-suggestion.hidden + %span.file-fork-suggestion-note + You don't have permission to edit this file. Try forking this project to edit the file. + = link_to 'Fork', fork_path, method: :post, class: 'btn btn-grouped btn-inverted btn-new' + %button.js-cancel-fork-suggestion.btn.btn-grouped{ type: 'button' } + Cancel = render blob.to_partial_path(@project), blob: blob diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml index deeeae3d64a..6000e057ec4 100644 --- a/app/views/projects/blob/_header.html.haml +++ b/app/views/projects/blob/_header.html.haml @@ -32,8 +32,8 @@ = link_to 'Permalink', namespace_project_blob_path(@project.namespace, @project, tree_join(@commit.sha, @path)), class: 'btn btn-sm js-data-file-blob-permalink-url' - - if current_user - .btn-group{ role: "group" }< - = edit_blob_link if blob_text_viewable?(blob) + .btn-group{ role: "group" }< + = edit_blob_link if blob_text_viewable?(blob) + - if current_user = replace_blob_link = delete_blob_link diff --git a/app/views/projects/builds/_sidebar.html.haml b/app/views/projects/builds/_sidebar.html.haml index 6f45d5b0689..f4a66398c85 100644 --- a/app/views/projects/builds/_sidebar.html.haml +++ b/app/views/projects/builds/_sidebar.html.haml @@ -68,7 +68,7 @@ - elsif @build.runner \##{@build.runner.id} .btn-group.btn-group-justified{ role: :group } - - if @build.has_trace_file? + - if @build.has_trace? = link_to 'Raw', raw_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' - if @build.active? = link_to "Cancel", cancel_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post diff --git a/app/views/projects/builds/_table.html.haml b/app/views/projects/builds/_table.html.haml index acfdb250aff..82806f022ee 100644 --- a/app/views/projects/builds/_table.html.haml +++ b/app/views/projects/builds/_table.html.haml @@ -20,6 +20,6 @@ %th Coverage %th - = render partial: "projects/ci/builds/build", collection: builds, as: :build, locals: { commit_sha: true, ref: true, pipeline_link: true, stage: true, allow_retry: true, coverage: admin || project.build_coverage_enabled?, admin: admin } + = render partial: "projects/ci/builds/build", collection: builds, as: :build, locals: { commit_sha: true, ref: true, pipeline_link: true, stage: true, allow_retry: true, admin: admin } = paginate builds, theme: 'gitlab' diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index aeed293a724..508465cbfb7 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -4,7 +4,6 @@ - retried = local_assigns.fetch(:retried, false) - pipeline_link = local_assigns.fetch(:pipeline_link, false) - stage = local_assigns.fetch(:stage, false) -- coverage = local_assigns.fetch(:coverage, false) - allow_retry = local_assigns.fetch(:allow_retry, false) %tr.build.commit{ class: ('retried' if retried) } @@ -88,7 +87,7 @@ %span= time_ago_with_tooltip(build.finished_at) %td.coverage - - if coverage && build.try(:coverage) + - if build.try(:coverage) #{build.coverage}% %td diff --git a/app/views/projects/commit/_pipeline.html.haml b/app/views/projects/commit/_pipeline.html.haml index c2b32a22170..3ee85723ebe 100644 --- a/app/views/projects/commit/_pipeline.html.haml +++ b/app/views/projects/commit/_pipeline.html.haml @@ -47,7 +47,6 @@ %th Job ID %th Name %th - - if pipeline.project.build_coverage_enabled? - %th Coverage + %th Coverage %th = render partial: "projects/stage/stage", collection: pipeline.stages, as: :stage diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml index d5fc283aa8d..0d11da2451a 100644 --- a/app/views/projects/commit/show.html.haml +++ b/app/views/projects/commit/show.html.haml @@ -10,6 +10,7 @@ - else .block-connector = render "projects/diffs/diffs", diffs: @diffs, environment: @environment + = render "projects/notes/notes_with_form" - if can_collaborate_with_project? - %w(revert cherry-pick).each do |type| diff --git a/app/views/projects/diffs/_line.html.haml b/app/views/projects/diffs/_line.html.haml index c09c7b87e24..3e426ee9e7d 100644 --- a/app/views/projects/diffs/_line.html.haml +++ b/app/views/projects/diffs/_line.html.haml @@ -4,7 +4,7 @@ - type = line.type - line_code = diff_file.line_code(line) - if discussions && !line.meta? - - discussion = discussions[line_code] + - line_discussions = discussions[line_code] %tr.line_holder{ class: type, id: (line_code unless plain) } - case type - when 'match' @@ -20,6 +20,7 @@ = link_text - else %a{ href: "##{line_code}", data: { linenumber: link_text } } + - discussion = line_discussions.try(:first) - if discussion && discussion.resolvable? && !plain %diff-note-avatars{ "discussion-id" => discussion.id } %td.new_line.diff-line-num{ class: type, data: { linenumber: line.new_pos } } @@ -34,6 +35,6 @@ - else = diff_line_content(line.text) -- if discussion - - discussion_expanded = local_assigns.fetch(:discussion_expanded, discussion.expanded?) - = render "discussions/diff_discussion", discussion: discussion, expanded: discussion_expanded +- if line_discussions + - discussion_expanded = local_assigns.fetch(:discussion_expanded, line_discussions.any?(&:expanded?)) + = render "discussions/diff_discussion", discussions: line_discussions, expanded: discussion_expanded diff --git a/app/views/projects/diffs/_parallel_view.html.haml b/app/views/projects/diffs/_parallel_view.html.haml index b7346f27ddb..f920f359de2 100644 --- a/app/views/projects/diffs/_parallel_view.html.haml +++ b/app/views/projects/diffs/_parallel_view.html.haml @@ -6,7 +6,7 @@ - right = line[:right] - last_line = right.new_pos if right - unless @diff_notes_disabled - - discussion_left, discussion_right = parallel_diff_discussions(left, right, diff_file) + - discussions_left, discussions_right = parallel_diff_discussions(left, right, diff_file) %tr.line_holder.parallel - if left - case left.type @@ -20,6 +20,7 @@ - left_position = diff_file.position(left) %td.old_line.diff-line-num.js-avatar-container{ id: left_line_code, class: left.type, data: { linenumber: left.old_pos } } %a{ href: "##{left_line_code}", data: { linenumber: left.old_pos } } + - discussion_left = discussions_left.try(:first) - if discussion_left && discussion_left.resolvable? %diff-note-avatars{ "discussion-id" => discussion_left.id } %td.line_content.parallel.noteable_line{ class: left.type, data: diff_view_line_data(left_line_code, left_position, 'old') }= diff_line_content(left.text) @@ -39,6 +40,7 @@ - right_position = diff_file.position(right) %td.new_line.diff-line-num.js-avatar-container{ id: right_line_code, class: right.type, data: { linenumber: right.new_pos } } %a{ href: "##{right_line_code}", data: { linenumber: right.new_pos } } + - discussion_right = discussions_right.try(:first) - if discussion_right && discussion_right.resolvable? %diff-note-avatars{ "discussion-id" => discussion_right.id } %td.line_content.parallel.noteable_line{ class: right.type, data: diff_view_line_data(right_line_code, right_position, 'new') }= diff_line_content(right.text) @@ -46,8 +48,8 @@ %td.old_line.diff-line-num.empty-cell %td.line_content.parallel - - if discussion_left || discussion_right - = render "discussions/parallel_diff_discussion", discussion_left: discussion_left, discussion_right: discussion_right + - if discussions_left || discussions_right + = render "discussions/parallel_diff_discussion", discussions_left: discussions_left, discussions_right: discussions_right - if !diff_file.new_file && !diff_file.deleted_file && diff_file.diff_lines.any? - last_line = diff_file.diff_lines.last - if last_line.new_pos < total_lines diff --git a/app/views/projects/environments/_metrics_button.html.haml b/app/views/projects/environments/_metrics_button.html.haml index e27281d6917..b4102fcf103 100644 --- a/app/views/projects/environments/_metrics_button.html.haml +++ b/app/views/projects/environments/_metrics_button.html.haml @@ -1,6 +1,6 @@ - environment = local_assigns.fetch(:environment) -- return unless environment.has_metrics? && can?(current_user, :read_environment, environment) +- return unless can?(current_user, :read_environment, environment) = link_to environment_metrics_path(environment), title: 'See metrics', class: 'btn metrics-button' do = icon('area-chart') diff --git a/app/views/projects/environments/metrics.html.haml b/app/views/projects/environments/metrics.html.haml index 92dc58cd38d..2e54af698aa 100644 --- a/app/views/projects/environments/metrics.html.haml +++ b/app/views/projects/environments/metrics.html.haml @@ -5,7 +5,7 @@ = page_specific_javascript_bundle_tag('monitoring') = render "projects/pipelines/head" -%div{ class: container_class } +.prometheus-container{ class: container_class, 'data-has-metrics': "#{@environment.has_metrics?}" } .top-area .row .col-sm-6 @@ -16,13 +16,68 @@ .col-sm-6 .nav-controls = render 'projects/deployments/actions', deployment: @environment.last_deployment - .row - .col-sm-12 - %h4 - CPU utilization - %svg.prometheus-graph{ 'graph-type' => 'cpu_values' } - .row - .col-sm-12 - %h4 - Memory usage - %svg.prometheus-graph{ 'graph-type' => 'memory_values' } + .prometheus-state + .js-getting-started.hidden + .row + .col-md-4.col-md-offset-4.state-svg + = render "shared/empty_states/monitoring/getting_started.svg" + .row + .col-md-6.col-md-offset-3 + %h4.text-center.state-title + Get started with performance monitoring + .row + .col-md-6.col-md-offset-3 + .description-text.text-center.state-description + Stay updated about the performance and health of your environment by configuring Prometheus to monitor your deployments. + = link_to help_page_path('administration/monitoring/prometheus/index.md') do + Learn more about performance monitoring + .row.state-button-section + .col-md-4.col-md-offset-4.text-center.state-button + = link_to edit_namespace_project_service_path(@project.namespace, @project, 'prometheus'), class: 'btn btn-success' do + Configure Prometheus + .js-loading.hidden + .row + .col-md-4.col-md-offset-4.state-svg + = render "shared/empty_states/monitoring/loading.svg" + .row + .col-md-6.col-md-offset-3 + %h4.text-center.state-title + Waiting for performance data + .row + .col-md-6.col-md-offset-3 + .description-text.text-center.state-description + Creating graphs uses the data from the Prometheus server. If this takes a long time, ensure that data is available. + .row.state-button-section + .col-md-4.col-md-offset-4.text-center.state-button + = link_to help_page_path('administration/monitoring/prometheus/index.md'), class: 'btn btn-success' do + View documentation + .js-unable-to-connect.hidden + .row + .col-md-4.col-md-offset-4.state-svg + = render "shared/empty_states/monitoring/unable_to_connect.svg" + .row + .col-md-6.col-md-offset-3 + %h4.text-center.state-title + Unable to connect to Prometheus server + .row + .col-md-6.col-md-offset-3 + .description-text.text-center.state-description + Ensure connectivity is available from the GitLab server to the + = link_to edit_namespace_project_service_path(@project.namespace, @project, 'prometheus') do + Prometheus server + .row.state-button-section + .col-md-4.col-md-offset-4.text-center.state-button + = link_to help_page_path('administration/monitoring/prometheus/index.md'), class:'btn btn-success' do + View documentation + + .prometheus-graphs + .row + .col-sm-12 + %h4 + CPU utilization + %svg.prometheus-graph{ 'graph-type' => 'cpu_values' } + .row + .col-sm-12 + %h4 + Memory usage + %svg.prometheus-graph{ 'graph-type' => 'memory_values' } diff --git a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml index 07fb80750d6..f458646522c 100644 --- a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml +++ b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml @@ -4,7 +4,6 @@ - retried = local_assigns.fetch(:retried, false) - pipeline_link = local_assigns.fetch(:pipeline_link, false) - stage = local_assigns.fetch(:stage, false) -- coverage = local_assigns.fetch(:coverage, false) %tr.generic_commit_status{ class: ('retried' if retried) } %td.status @@ -80,7 +79,7 @@ %span= time_ago_with_tooltip(generic_commit_status.finished_at) %td.coverage - - if coverage && generic_commit_status.try(:coverage) + - if generic_commit_status.try(:coverage) #{generic_commit_status.coverage}% %td diff --git a/app/views/projects/merge_requests/_discussion.html.haml b/app/views/projects/merge_requests/_discussion.html.haml index cfb44bd206c..15b5a51c1d0 100644 --- a/app/views/projects/merge_requests/_discussion.html.haml +++ b/app/views/projects/merge_requests/_discussion.html.haml @@ -1,9 +1,9 @@ - content_for :note_actions do - if can?(current_user, :update_merge_request, @merge_request) - if @merge_request.open? - = link_to 'Close merge request', merge_request_path(@merge_request, merge_request: {state_event: :close }), method: :put, class: "btn btn-nr btn-comment btn-close close-mr-link js-note-target-close", title: "Close merge request", data: {original_text: "Close merge request", alternative_text: "Comment & close merge request"} + = link_to 'Close merge request', merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, class: "btn btn-nr btn-comment btn-close close-mr-link js-note-target-close", title: "Close merge request", data: { original_text: "Close merge request", alternative_text: "Comment & close merge request"} - if @merge_request.reopenable? - = link_to 'Reopen merge request', merge_request_path(@merge_request, merge_request: {state_event: :reopen }), method: :put, class: "btn btn-nr btn-comment btn-reopen reopen-mr-link js-note-target-reopen", title: "Reopen merge request", data: {original_text: "Reopen merge request", alternative_text: "Comment & reopen merge request"} + = link_to 'Reopen merge request', merge_request_path(@merge_request, merge_request: { state_event: :reopen }), method: :put, class: "btn btn-nr btn-comment btn-reopen reopen-mr-link js-note-target-close js-note-target-reopen", title: "Reopen merge request", data: { original_text: "Reopen merge request", alternative_text: "Comment & reopen merge request"} %comment-and-resolve-btn{ "inline-template" => true, ":discussion-id" => "" } %button.btn.btn-nr.btn-default.append-right-10.js-comment-resolve-button{ "v-if" => "showButton", type: "submit", data: { project_path: "#{project_path(@merge_request.project)}" } } {{ buttonText }} diff --git a/app/views/projects/notes/_comment_button.html.haml b/app/views/projects/notes/_comment_button.html.haml new file mode 100644 index 00000000000..008363263b3 --- /dev/null +++ b/app/views/projects/notes/_comment_button.html.haml @@ -0,0 +1,28 @@ +- noteable_name = @note.noteable.human_class_name + +.pull-left.btn-group.append-right-10.comment-type-dropdown.js-comment-type-dropdown + %input.btn.btn-nr.btn-create.comment-btn.js-comment-button.js-comment-submit-button{ type: 'submit', value: 'Comment' } + + - if @note.can_be_discussion_note? + = button_tag type: 'button', class: 'btn btn-nr dropdown-toggle comment-btn js-note-new-discussion js-disable-on-submit', data: { 'dropdown-trigger' => '#resolvable-comment-menu' }, 'aria-label' => 'Open comment type dropdown' do + = icon('caret-down') + + %ul#resolvable-comment-menu.dropdown-menu{ data: { dropdown: true } } + %li#comment.droplab-item-selected{ data: { value: '', 'button-text' => 'Comment', 'secondary-button-text' => "Comment & close #{noteable_name}" } } + = icon('check') + .description + %strong Comment + %p + Add a general comment to this #{noteable_name}. + + %li.divider + + %li#discussion{ data: { value: 'DiscussionNote', 'button-text' => 'Start discussion', 'secondary-button-text' => "Start discussion & close #{noteable_name}" } } + = icon('check') + .description + %strong Start discussion + %p + = succeed '.' do + Discuss a specific suggestion or question + - if @note.noteable.supports_resolvable_notes? + that needs to be resolved diff --git a/app/views/projects/notes/_form.html.haml b/app/views/projects/notes/_form.html.haml index b561052e721..0d835a9e949 100644 --- a/app/views/projects/notes/_form.html.haml +++ b/app/views/projects/notes/_form.html.haml @@ -4,12 +4,18 @@ = hidden_field_tag :view, diff_view = hidden_field_tag :line_type = hidden_field_tag :merge_request_diff_head_sha, @note.noteable.try(:diff_head_sha) + = hidden_field_tag :in_reply_to_discussion_id + = note_target_fields(@note) - = f.hidden_field :commit_id - = f.hidden_field :line_code - = f.hidden_field :noteable_id = f.hidden_field :noteable_type + = f.hidden_field :noteable_id + = f.hidden_field :commit_id = f.hidden_field :type + + -# LegacyDiffNote + = f.hidden_field :line_code + + -# DiffNote = f.hidden_field :position = render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do @@ -22,7 +28,9 @@ .error-alert .note-form-actions.clearfix - = f.submit 'Comment', class: "btn btn-nr btn-create append-right-10 comment-btn js-comment-button" + = render partial: 'projects/notes/comment_button' + = yield(:note_actions) + %a.btn.btn-cancel.js-note-discard{ role: "button", data: {cancel_text: "Cancel" } } Discard draft diff --git a/app/views/projects/notes/_note.html.haml b/app/views/projects/notes/_note.html.haml index 6c0e6d48d6c..b1b1b66b7f2 100644 --- a/app/views/projects/notes/_note.html.haml +++ b/app/views/projects/notes/_note.html.haml @@ -31,7 +31,7 @@ - if note.resolvable? - can_resolve = can?(current_user, :resolve_note, note) %resolve-btn{ "project-path" => project_path(note.project), - "discussion-id" => note.discussion_id, + "discussion-id" => note.discussion_id(@noteable), ":note-id" => note.id, ":resolved" => note.resolved?, ":can-resolve" => can_resolve, @@ -59,7 +59,9 @@ - if note.emoji_awardable? = link_to '#', title: 'Award Emoji', class: 'note-action-button note-emoji-button js-add-award js-note-emoji', data: { position: 'right' } do = icon('spinner spin') - = icon('smile-o', class: 'link-highlight') + %span{ class: "link-highlight award-control-icon-neutral" }= custom_icon('emoji_slightly_smiling_face') + %span{ class: "link-highlight award-control-icon-positive" }= custom_icon('emoji_smiley') + %span{ class: "link-highlight award-control-icon-super-positive" }= custom_icon('emoji_smile') - if note_editable = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do diff --git a/app/views/projects/notes/_notes.html.haml b/app/views/projects/notes/_notes.html.haml index 022578bd6db..2b2bab09c74 100644 --- a/app/views/projects/notes/_notes.html.haml +++ b/app/views/projects/notes/_notes.html.haml @@ -1,7 +1,7 @@ -- if @discussions.present? +- if defined?(@discussions) - @discussions.each do |discussion| - - if discussion.for_target?(@noteable) - = render partial: "projects/notes/note", object: discussion.first_note, as: :note + - if discussion.individual_note? + = render partial: "projects/notes/note", collection: discussion.notes, as: :note - else = render 'discussions/discussion', discussion: discussion - else diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index 53067cdcba4..d7cefb8613e 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -36,7 +36,6 @@ %th Job ID %th Name %th - - if pipeline.project.build_coverage_enabled? - %th Coverage + %th Coverage %th = render partial: "projects/stage/stage", collection: pipeline.stages, as: :stage diff --git a/app/views/projects/registry/repositories/_image.html.haml b/app/views/projects/registry/repositories/_image.html.haml new file mode 100644 index 00000000000..d183ce34a3a --- /dev/null +++ b/app/views/projects/registry/repositories/_image.html.haml @@ -0,0 +1,32 @@ +.container-image.js-toggle-container + .container-image-head + = link_to "#", class: "js-toggle-button" do + = icon('chevron-down', 'aria-hidden': 'true') + = escape_once(image.path) + + = clipboard_button(clipboard_text: "docker pull #{image.path}") + + .controls.hidden-xs.pull-right + = link_to namespace_project_container_registry_path(@project.namespace, @project, image), + class: 'btn btn-remove has-tooltip', + title: 'Remove repository', + data: { confirm: 'Are you sure?' }, + method: :delete do + = icon('trash cred', 'aria-hidden': 'true') + + .container-image-tags.js-toggle-content.hide + - if image.has_tags? + .table-holder + %table.table.tags + %thead + %tr + %th Tag + %th Tag ID + %th Size + %th Created + - if can?(current_user, :update_container_image, @project) + %th + = render partial: 'tag', collection: image.tags + - else + .nothing-here-block No tags in Container Registry for this container image. + diff --git a/app/views/projects/container_registry/_tag.html.haml b/app/views/projects/registry/repositories/_tag.html.haml index 10822b6184c..ee1ec0e8f9a 100644 --- a/app/views/projects/container_registry/_tag.html.haml +++ b/app/views/projects/registry/repositories/_tag.html.haml @@ -25,5 +25,9 @@ - if can?(current_user, :update_container_image, @project) %td.content .controls.hidden-xs.pull-right - = link_to namespace_project_container_registry_path(@project.namespace, @project, tag.name), class: 'btn btn-remove has-tooltip', title: "Remove", data: { confirm: "Are you sure?" }, method: :delete do - = icon("trash cred") + = link_to namespace_project_registry_repository_tag_path(@project.namespace, @project, tag.repository, tag.name), + method: :delete, + class: 'btn btn-remove has-tooltip', + title: 'Remove tag', + data: { confirm: 'Are you sure you want to delete this tag?' } do + = icon('trash cred') diff --git a/app/views/projects/container_registry/index.html.haml b/app/views/projects/registry/repositories/index.html.haml index 993da27310f..be128e92fa7 100644 --- a/app/views/projects/container_registry/index.html.haml +++ b/app/views/projects/registry/repositories/index.html.haml @@ -15,25 +15,12 @@ %br Then you are free to create and upload a container image with build and push commands: %pre - docker build -t #{escape_once(@project.container_registry_repository_url)} . + docker build -t #{escape_once(@project.container_registry_url)}/image . %br - docker push #{escape_once(@project.container_registry_repository_url)} + docker push #{escape_once(@project.container_registry_url)}/image - - if @tags.blank? - %li - .nothing-here-block No images in Container Registry for this project. + - if @images.blank? + .nothing-here-block No container image repositories in Container Registry for this project. - else - .table-holder - %table.table.tags - %thead - %tr - %th Name - %th Image ID - %th Size - %th Created - - if can?(current_user, :update_container_image, @project) - %th - - - @tags.each do |tag| - = render 'tag', tag: tag + = render partial: 'image', collection: @images diff --git a/app/views/projects/stage/_stage.html.haml b/app/views/projects/stage/_stage.html.haml index 28e1c060875..f93994bebe3 100644 --- a/app/views/projects/stage/_stage.html.haml +++ b/app/views/projects/stage/_stage.html.haml @@ -6,8 +6,8 @@ = ci_icon_for_status(stage.status) = stage.name.titleize -= render stage.statuses.latest_ordered, coverage: @project.build_coverage_enabled?, stage: false, ref: false, pipeline_link: false, allow_retry: true -= render stage.statuses.retried_ordered, coverage: @project.build_coverage_enabled?, stage: false, ref: false, pipeline_link: false, retried: true += render stage.statuses.latest_ordered, stage: false, ref: false, pipeline_link: false, allow_retry: true += render stage.statuses.retried_ordered, stage: false, ref: false, pipeline_link: false, retried: true %tr %td{ colspan: 10 } diff --git a/app/views/shared/empty_states/monitoring/_getting_started.svg b/app/views/shared/empty_states/monitoring/_getting_started.svg new file mode 100644 index 00000000000..db7a1c2e708 --- /dev/null +++ b/app/views/shared/empty_states/monitoring/_getting_started.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 406 305" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="0" width="159.8" height="127.81" x=".196" y="5" rx="10"/><rect id="2" width="160" height="128" x=".666" y=".41" rx="10"/><rect id="4" width="160.19" height="128.19" x=".339" y=".59" rx="10"/><mask id="1" width="159.8" height="127.81" x="0" y="0" fill="#fff"><use xlink:href="#0"/></mask><mask id="3" width="160" height="128" x="0" y="0" fill="#fff"><use xlink:href="#2"/></mask><mask id="5" width="160.19" height="128.19" x="0" y="0" fill="#fff"><use xlink:href="#4"/></mask></defs><g fill="none" fill-rule="evenodd" transform="translate(12 3)"><rect width="160" height="128" x="122.08" y="146.08" fill="#f9f9f9" transform="matrix(.99619.08716-.08716.99619 19.08-16.813)" rx="10"/><g transform="matrix(.96593.25882-.25882.96593 227.1 57.47)"><rect width="159.8" height="127.81" x="1.64" y="10.06" fill="#f9f9f9" rx="8"/><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#1)" xlink:href="#0"/><g transform="translate(24.368 36.951)"><path fill="#d2caea" fill-rule="nonzero" d="m71.785 44.2c.761.296 1.625.099 2.184-.496l35.956-38.34c.756-.806.715-2.071-.091-2.827-.806-.756-2.071-.715-2.827.091l-35.03 37.36-41.888-16.285c-.749-.291-1.6-.106-2.16.471l-26.368 27.16c-.769.793-.751 2.059.042 2.828.793.769 2.059.751 2.828-.042l25.444-26.21 41.911 16.294"/><g fill="#fff"><circle cx="5.716" cy="5.104" r="5" stroke="#6b4fbb" stroke-width="4" transform="translate(65.917 34.945)"/><g stroke="#fb722e"><ellipse cx="4.632" cy="50.05" stroke-width="3.2" rx="4" ry="3.999"/><g stroke-width="4"><ellipse cx="29.632" cy="27.05" rx="4" ry="3.999"/><ellipse cx="107.63" cy="4.048" rx="4" ry="3.999"/></g></g></g></g></g><rect width="160.19" height="128.19" x="36.28" y="86.74" fill="#f9f9f9" transform="matrix(.99619-.08716.08716.99619-12.703 10.717)" rx="10"/><g transform="matrix(.99619.08716-.08716.99619 126.61 137.8)"><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#3)" xlink:href="#2"/><path fill="#6b4fbb" stroke="#6b4fbb" stroke-width="3.2" d="m84.67 28.41c18.225 0 33 15.07 33 33.651h-33v-33.651" stroke-linecap="round" stroke-linejoin="round"/><path fill="#d2caea" fill-rule="nonzero" d="m78.67 66.41h30c1.105 0 2 .895 2 2 0 18.778-15.222 34-34 34-18.778 0-34-15.222-34-34 0-18.778 15.222-34 34-34 1.105 0 2 .895 2 2v30m-32 2c0 16.569 13.431 30 30 30 15.896 0 28.905-12.364 29.934-28h-29.934c-1.105 0-2-.895-2-2v-29.934c-15.636 1.029-28 14.04-28 29.934"/></g><g transform="matrix(.99619-.08716.08716.99619 30 88.03)"><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#5)" xlink:href="#4"/><g transform="translate(42 34)"><path fill="#fef0ea" d="m0 13.391c0-.768.628-1.391 1.4-1.391h9.2c.773 0 1.4.626 1.4 1.391v49.609h-12v-49.609"/><path fill="#fb722e" d="m66 21.406c0-.777.628-1.406 1.4-1.406h9.2c.773 0 1.4.624 1.4 1.406v41.594h-12v-41.594"/><path fill="#6b4fbb" d="m22 1.404c0-.776.628-1.404 1.4-1.404h9.2c.773 0 1.4.624 1.4 1.404v61.6h-12v-61.6"/><path fill="#d2caea" d="m44 39.4c0-.772.628-1.398 1.4-1.398h9.2c.773 0 1.4.618 1.4 1.398v23.602h-12v-23.602"/></g></g><g fill="#fee8dc"><path d="m6.226 94.95l-2.84.631c-1.075.239-1.752-.445-1.515-1.515l.631-2.84-.631-2.84c-.239-1.075.445-1.752 1.515-1.515l2.84.631 2.84-.631c1.075-.239 1.752.445 1.515 1.515l-.631 2.84.631 2.84c.239 1.075-.445 1.752-1.515 1.515l-2.84-.631" transform="matrix(.70711.70711-.70711.70711 66.33 22.317)"/><path d="m312.78 53.43l-3.634.807c-1.296.288-2.115-.52-1.825-1.825l.807-3.634-.807-3.634c-.288-1.296.52-2.115 1.825-1.825l3.634.807 3.634-.807c1.296-.288 2.115.52 1.825 1.825l-.807 3.634.807 3.634c.288 1.296-.52 2.115-1.825 1.825l-3.634-.807" transform="matrix(.70711.70711-.70711.70711 126.1-206.88)"/></g><path fill="#e1dcf1" d="m124.78 12.43l-3.617.804c-1.306.29-2.129-.53-1.839-1.839l.804-3.617-.804-3.617c-.29-1.306.53-2.129 1.839-1.839l3.617.804 3.617-.804c1.306-.29 2.129.53 1.839 1.839l-.804 3.617.804 3.617c.29 1.306-.53 2.129-1.839 1.839l-3.617-.804" transform="matrix(.70711-.70711.70711.70711 31.05 90.51)"/><path fill="#d2caea" d="m374.78 244.43l-3.617.804c-1.306.29-2.129-.53-1.839-1.839l.804-3.617-.804-3.617c-.29-1.306.53-2.129 1.839-1.839l3.617.804 3.617-.804c1.306-.29 2.129.53 1.839 1.839l-.804 3.617.804 3.617c.29 1.306-.53 2.129-1.839 1.839l-3.617-.804" transform="matrix(.70711-.70711.70711.70711-59.779 335.24)"/></g></svg>
\ No newline at end of file diff --git a/app/views/shared/empty_states/monitoring/_loading.svg b/app/views/shared/empty_states/monitoring/_loading.svg new file mode 100644 index 00000000000..6bbd7a6c5b9 --- /dev/null +++ b/app/views/shared/empty_states/monitoring/_loading.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 406 305" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="C" width="161" height="100" x="92" y="181" rx="10"/><rect id="E" width="151" height="32" x="20" rx="10"/><rect id="G" width="191" height="62" y="10" rx="10"/><circle id="I" cx="23" cy="41" r="9"/><circle id="4" cx="36.5" cy="36.5" r="36.5"/><circle id="8" cx="262.5" cy="169.5" r="15.5"/><circle id="A" cx="79.5" cy="169.5" r="15.5"/><circle id="K" cx="45" cy="41" r="9"/><circle id="0" cx="30.5" cy="30.5" r="30.5"/><circle id="2" cx="18" cy="34" r="3"/><ellipse id="6" cx="43.5" cy="43.5" rx="43.5" ry="43.5"/><mask id="H" width="191" height="62" x="0" y="0" fill="#fff"><use xlink:href="#G"/></mask><mask id="J" width="18" height="18" x="0" y="0" fill="#fff"><use xlink:href="#I"/></mask><mask id="D" width="161" height="100" x="0" y="0" fill="#fff"><use xlink:href="#C"/></mask><mask id="F" width="151" height="32" x="0" y="0" fill="#fff"><use xlink:href="#E"/></mask><mask id="9" width="31" height="31" x="0" y="0" fill="#fff"><use xlink:href="#8"/></mask><mask id="1" width="61" height="61" x="0" y="0" fill="#fff"><use xlink:href="#0"/></mask><mask id="B" width="31" height="31" x="0" y="0" fill="#fff"><use xlink:href="#A"/></mask><mask id="3" width="6" height="6" x="0" y="0" fill="#fff"><use xlink:href="#2"/></mask><mask id="7" width="87" height="87" x="0" y="0" fill="#fff"><use xlink:href="#6"/></mask><mask id="L" width="18" height="18" x="0" y="0" fill="#fff"><use xlink:href="#K"/></mask><mask id="5" width="73" height="73" x="0" y="0" fill="#fff"><use xlink:href="#4"/></mask></defs><g fill="none" fill-rule="evenodd" transform="translate(28 2)"><g transform="translate(133 87)"><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#1)" xlink:href="#0"/><path stroke="#d2caea" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" d="m19 32l2-9 5 17 4-12 4 5 6-10 3 5"/><g fill="#fff" stroke="#fb722e"><use stroke-width="4" mask="url(#3)" xlink:href="#2"/><circle cx="44" cy="30" r="2" stroke-width="2"/></g></g><g transform="translate(188 29)"><circle cx="36.5" cy="41.5" r="36.5" fill="#f9f9f9"/><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#5)" xlink:href="#4"/><rect width="27" height="4" x="23" y="27" fill="#d2caea" rx="2"/><rect width="10.5" height="4" x="23" y="27" fill="#6b4fbb" rx="2"/><rect width="27" height="4" x="23" y="36" fill="#d2caea" rx="2"/><rect width="19" height="4" x="23" y="36" fill="#6b4fbb" rx="2"/><rect width="27" height="4" x="23" y="45" fill="#d2caea" rx="2"/><rect width="7" height="4" x="23" y="45" fill="#6b4fbb" rx="2"/></g><path fill="#eee" fill-rule="nonzero" d="m247 292v1c0 5.519-4.469 9.993-10.01 9.993h-125.99c-5.177 0-9.436-3.927-9.954-8.96 1.348.998 2.957 1.666 4.705 1.883 1.027 1.835 2.992 3.077 5.248 3.077h125.99c2.485 0 4.611-1.497 5.526-3.637 1.796-.675 3.347-1.852 4.48-3.359m1.947-8.962c-.518 5.03-4.774 8.958-9.95 8.958h-131.99c-4.929 0-9.03-3.563-9.851-8.25 1.382.767 2.964 1.216 4.649 1.248 1.037 1.794 2.978 3 5.202 3h131.99c2.255 0 4.219-1.241 5.245-3.076 1.748-.216 3.356-.883 4.705-1.882"/><g transform="translate(79)"><ellipse cx="43.5" cy="47.5" fill="#f9f9f9" rx="43.5" ry="43.5"/><g fill="#fff"><g stroke="#eee"><use stroke-width="8" mask="url(#7)" xlink:href="#6"/><path stroke-width="4" d="m18.595 49c2.515 11.44 12.71 20 24.905 20 14.08 0 25.5-11.417 25.5-25.5 0-12.195-8.56-22.391-20-24.905v15.959c3 1.848 5 5.164 5 8.946 0 5.799-4.701 10.5-10.5 10.5-3.782 0-7.098-2-8.946-5h-15.959" stroke-linejoin="round"/></g><path stroke="#d2caea" stroke-width="4" d="m18 44c-.003-.166-.005-.333-.005-.5 0-14.08 11.417-25.5 25.5-25.5.167 0 .334.002.5.005v15.01c-.166-.008-.332-.012-.5-.012-5.799 0-10.5 4.701-10.5 10.5 0 .168.004.334.012.5h-15.01" stroke-linejoin="round"/></g></g><g fill="#fff" stroke="#eee" stroke-width="8"><use mask="url(#9)" xlink:href="#8"/><use mask="url(#B)" xlink:href="#A"/><use mask="url(#D)" xlink:href="#C"/></g><g fill="#eee"><rect width="15" height="2" x="226" y="247" rx="1"/><rect width="15" height="2" x="226" y="242" rx="1"/><rect width="15" height="2" x="226" y="252" rx="1"/></g><rect width="10" height="52" x="118" y="196" fill="#d2caea" rx="2"/><rect width="10" height="47" x="154" y="196" fill="#6b4fbb" rx="2"/><rect width="10" height="37" x="190" y="196" fill="#d2caea" rx="2"/><g fill="#fee8dc"><rect width="10" height="52" x="132" y="185" rx="2"/><rect width="10" height="38" x="168" y="185" rx="2"/></g><rect width="10" height="58" x="204" y="185" fill="#fb722e" rx="2"/><g transform="translate(76 128)"><g fill="#fff" stroke="#eee" stroke-width="8"><use mask="url(#F)" xlink:href="#E"/><use mask="url(#H)" xlink:href="#G"/></g><g fill="#d2caea"><rect width="16" height="4" x="156" y="35" rx="2"/><rect width="16" height="4" x="156" y="43" rx="2"/></g><g fill="#fff" stroke-width="8"><use stroke="#fee8dc" mask="url(#J)" xlink:href="#I"/><use stroke="#fb722e" mask="url(#L)" xlink:href="#K"/></g></g><g fill="#fb722e"><path d="m6.226 220.95l-2.84.631c-1.075.239-1.752-.445-1.515-1.515l.631-2.84-.631-2.84c-.239-1.075.445-1.752 1.515-1.515l2.84.631 2.84-.631c1.075-.239 1.752.445 1.515 1.515l-.631 2.84.631 2.84c.239 1.075-.445 1.752-1.515 1.515l-2.84-.631" opacity=".2" transform="matrix(.70711.70711-.70711.70711 155.43 59.22)"/><path d="m256.23 9.95l-2.84.631c-1.075.239-1.752-.445-1.515-1.515l.631-2.84-.631-2.84c-.239-1.075.445-1.752 1.515-1.515l2.84.631 2.84-.631c1.075-.239 1.752.445 1.515 1.515l-.631 2.84.631 2.84c.239 1.075-.445 1.752-1.515 1.515l-2.84-.631" opacity=".2" transform="matrix(.70711.70711-.70711.70711 79.45-179.36)"/></g><path fill="#fee8dc" d="m312.78 150.43l-3.634.807c-1.296.288-2.115-.52-1.825-1.825l.807-3.634-.807-3.634c-.288-1.296.52-2.115 1.825-1.825l3.634.807 3.634-.807c1.296-.288 2.115.52 1.825 1.825l-.807 3.634.807 3.634c.288 1.296-.52 2.115-1.825 1.825l-3.634-.807" transform="matrix(.70711.70711-.70711.70711 194.69-178.47)"/><path fill="#6b4fbb" d="m43.778 80.43l-3.617.804c-1.306.29-2.129-.53-1.839-1.839l.804-3.617-.804-3.617c-.29-1.306.53-2.129 1.839-1.839l3.617.804 3.617-.804c1.306-.29 2.129.53 1.839 1.839l-.804 3.617.804 3.617c.29 1.306-.53 2.129-1.839 1.839l-3.617-.804" opacity=".2" transform="matrix(.70711-.70711.70711.70711-40.761 53.15)"/></g></svg>
\ No newline at end of file diff --git a/app/views/shared/empty_states/monitoring/_unable_to_connect.svg b/app/views/shared/empty_states/monitoring/_unable_to_connect.svg new file mode 100644 index 00000000000..62537d87d5d --- /dev/null +++ b/app/views/shared/empty_states/monitoring/_unable_to_connect.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 406 305" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><use id="0" xlink:href="#E"/><use id="2" xlink:href="#E"/><use id="4" xlink:href="#E"/><path id="6" d="m74 93h26v47h-26z"/><path id="8" d="m74 93h26v47h-26z"/><rect id="A" width="65" height="14" x="55" y="135" rx="4"/><rect id="C" width="175" height="118" rx="10"/><rect id="E" width="159" rx="10" height="56"/><rect id="F" width="160" y="2" rx="10" height="56" fill="#f9f9f9"/><mask id="B" width="65" height="14" x="0" y="0" fill="#fff"><use xlink:href="#A"/></mask><mask id="9" width="26" height="47" x="0" y="0" fill="#fff"><use xlink:href="#8"/></mask><mask id="D" width="175" height="118" x="0" y="0" fill="#fff"><use xlink:href="#C"/></mask><mask id="7" width="26" height="47" x="0" y="0" fill="#fff"><use xlink:href="#6"/></mask><mask id="3" width="159" height="56" x="0" y="0" fill="#fff"><use xlink:href="#2"/></mask><mask id="1" width="159" height="56" x="0" y="0" fill="#fff"><use xlink:href="#0"/></mask><mask id="5" width="159" height="56" x="0" y="0" fill="#fff"><use xlink:href="#4"/></mask></defs><g fill="none" fill-rule="evenodd" transform="translate(1 65)"><g transform="translate(244)"><use xlink:href="#F"/><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#1)" xlink:href="#0"/><g fill-rule="nonzero"><path fill="#fb722e" d="m134 31c1.105 0 2-.895 2-2 0-1.105-.895-2-2-2-1.105 0-2 .895-2 2 0 1.105.895 2 2 2m0 4c-3.314 0-6-2.686-6-6 0-3.314 2.686-6 6-6 3.314 0 6 2.686 6 6 0 3.314-2.686 6-6 6"/><path fill="#fee8dc" d="m117 31c1.105 0 2-.895 2-2 0-1.105-.895-2-2-2-1.105 0-2 .895-2 2 0 1.105.895 2 2 2m0 4c-3.314 0-6-2.686-6-6 0-3.314 2.686-6 6-6 3.314 0 6 2.686 6 6 0 3.314-2.686 6-6 6m-17-4c1.105 0 2-.895 2-2 0-1.105-.895-2-2-2-1.105 0-2 .895-2 2 0 1.105.895 2 2 2m0 4c-3.314 0-6-2.686-6-6 0-3.314 2.686-6 6-6 3.314 0 6 2.686 6 6 0 3.314-2.686 6-6 6"/></g><g fill="#d2caea"><rect width="50" height="4" x="19" y="20" rx="2"/><rect width="50" height="4" x="19" y="34" rx="2"/></g><g transform="translate(0 59)"><use xlink:href="#F"/><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#3)" xlink:href="#2"/><g fill-rule="nonzero"><path fill="#fee8dc" d="m134 30c1.105 0 2-.895 2-2 0-1.105-.895-2-2-2-1.105 0-2 .895-2 2 0 1.105.895 2 2 2m0 4c-3.314 0-6-2.686-6-6 0-3.314 2.686-6 6-6 3.314 0 6 2.686 6 6 0 3.314-2.686 6-6 6"/><path fill="#fb722e" d="m117 30c1.105 0 2-.895 2-2 0-1.105-.895-2-2-2-1.105 0-2 .895-2 2 0 1.105.895 2 2 2m0 4c-3.314 0-6-2.686-6-6 0-3.314 2.686-6 6-6 3.314 0 6 2.686 6 6 0 3.314-2.686 6-6 6"/><path fill="#fee8dc" d="m100 30c1.105 0 2-.895 2-2 0-1.105-.895-2-2-2-1.105 0-2 .895-2 2 0 1.105.895 2 2 2m0 4c-3.314 0-6-2.686-6-6 0-3.314 2.686-6 6-6 3.314 0 6 2.686 6 6 0 3.314-2.686 6-6 6"/></g><rect width="50" height="4" x="19" y="19" fill="#d2caea" rx="2" id="G"/><rect width="50" height="4" x="19" y="33" fill="#d2caea" rx="2" id="H"/></g><g transform="translate(0 118)"><use xlink:href="#F"/><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#5)" xlink:href="#4"/><g fill-rule="nonzero"><path fill="#fb722e" d="m134 30c1.105 0 2-.895 2-2 0-1.105-.895-2-2-2-1.105 0-2 .895-2 2 0 1.105.895 2 2 2m0 4c-3.314 0-6-2.686-6-6 0-3.314 2.686-6 6-6 3.314 0 6 2.686 6 6 0 3.314-2.686 6-6 6"/><path fill="#fee8dc" d="m117 30c1.105 0 2-.895 2-2 0-1.105-.895-2-2-2-1.105 0-2 .895-2 2 0 1.105.895 2 2 2m0 4c-3.314 0-6-2.686-6-6 0-3.314 2.686-6 6-6 3.314 0 6 2.686 6 6 0 3.314-2.686 6-6 6m-17-4c1.105 0 2-.895 2-2 0-1.105-.895-2-2-2-1.105 0-2 .895-2 2 0 1.105.895 2 2 2m0 4c-3.314 0-6-2.686-6-6 0-3.314 2.686-6 6-6 3.314 0 6 2.686 6 6 0 3.314-2.686 6-6 6"/></g><use xlink:href="#G"/><use xlink:href="#H"/></g></g><g transform="translate(163 55)"><g fill="#eee"><rect width="29" height="4" y="29" rx="2"/><rect width="28" height="4" x="55" y="29" rx="2"/></g><g transform="translate(16)"><circle cx="30" cy="30" r="24" fill="#fef0ea"/><g fill="#fb722e"><circle cx="30.5" cy="30.5" r="30.5" opacity=".1"/><circle cx="30.5" cy="30.5" r="19.5" opacity=".1"/></g><circle cx="30.5" cy="30.5" r="13.5" fill="#fff"/><path fill="#fb722e" d="m32.621 30.5l2.481-2.481c.586-.586.58-1.529-.006-2.115-.59-.59-1.533-.589-2.115-.006l-2.481 2.481-2.481-2.481c-.586-.586-1.529-.58-2.115.006-.59.59-.589 1.533-.006 2.115l2.481 2.481-2.481 2.481c-.586.586-.58 1.529.006 2.115.59.59 1.533.589 2.115.006l2.481-2.481 2.481 2.481c.586.586 1.529.58 2.115-.006.59-.59.589-1.533.006-2.115l-2.481-2.481"/></g></g><g transform="translate(0 13)"><rect width="65" height="14" x="55" y="137" fill="#f9f9f9" rx="4"/><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#7)" xlink:href="#6"/><rect width="175" height="118" y="3" fill="#f9f9f9" rx="10"/><g fill="#fff" stroke="#eee" stroke-width="8"><use mask="url(#9)" xlink:href="#8"/><use mask="url(#B)" xlink:href="#A"/><use mask="url(#D)" xlink:href="#C"/></g><g fill-rule="nonzero"><path fill="#eee" d="m163 105v-93h-152v93h152m-156-93.01c0-2.204 1.797-3.99 3.995-3.99h152.01c2.206 0 3.995 1.796 3.995 3.99v93.02c0 2.204-1.797 3.99-3.995 3.99h-152.01c-2.206 0-3.995-1.796-3.995-3.99v-93.02"/><path fill="#d2caea" d="m86 92c-11.598 0-21-9.402-21-21 0-11.598 9.402-21 21-21 11.598 0 21 9.402 21 21 0 11.598-9.402 21-21 21m0-4c9.389 0 17-7.611 17-17 0-9.389-7.611-17-17-17-9.389 0-17 7.611-17 17 0 9.389 7.611 17 17 17"/></g><path fill="#6b4fbb" d="m83 63c0-1.659 1.347-3 3-3 1.657 0 3 1.342 3 3v7.993c0 1.659-1.347 3-3 3-1.657 0-3-1.342-3-3v-7.993m3 18.997c-1.657 0-3-1.343-3-3 0-1.657 1.343-3 3-3 1.657 0 3 1.343 3 3 0 1.657-1.343 3-3 3"/><g fill="#eee"><rect width="134" height="4" x="20" y="30" rx="2"/><rect width="14" height="4" x="20" y="20" rx="2"/><circle cx="87" cy="21" r="5"/></g></g></g></svg>
\ No newline at end of file diff --git a/app/views/shared/icons/_code_fork.svg b/app/views/shared/icons/_code_fork.svg new file mode 100644 index 00000000000..8347dce2d5b --- /dev/null +++ b/app/views/shared/icons/_code_fork.svg @@ -0,0 +1 @@ +<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M672 1472q0-40-28-68t-68-28-68 28-28 68 28 68 68 28 68-28 28-68zm0-1152q0-40-28-68t-68-28-68 28-28 68 28 68 68 28 68-28 28-68zm640 128q0-40-28-68t-68-28-68 28-28 68 28 68 68 28 68-28 28-68zm96 0q0 52-26 96.5t-70 69.5q-2 287-226 414-68 38-203 81-128 40-169.5 71t-41.5 100v26q44 25 70 69.5t26 96.5q0 80-56 136t-136 56-136-56-56-136q0-52 26-96.5t70-69.5v-820q-44-25-70-69.5t-26-96.5q0-80 56-136t136-56 136 56 56 136q0 52-26 96.5t-70 69.5v497q54-26 154-57 55-17 87.5-29.5t70.5-31 59-39.5 40.5-51 28-69.5 8.5-91.5q-44-25-70-69.5t-26-96.5q0-80 56-136t136-56 136 56 56 136z"/></svg>
\ No newline at end of file diff --git a/app/views/shared/icons/_comment_o.svg b/app/views/shared/icons/_comment_o.svg new file mode 100644 index 00000000000..55807f0840a --- /dev/null +++ b/app/views/shared/icons/_comment_o.svg @@ -0,0 +1 @@ +<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M896 384q-204 0-381.5 69.5t-282 187.5-104.5 255q0 112 71.5 213.5t201.5 175.5l87 50-27 96q-24 91-70 172 152-63 275-171l43-38 57 6q69 8 130 8 204 0 381.5-69.5t282-187.5 104.5-255-104.5-255-282-187.5-381.5-69.5zm896 512q0 174-120 321.5t-326 233-450 85.5q-70 0-145-8-198 175-460 242-49 14-114 22h-5q-15 0-27-10.5t-16-27.5v-1q-3-4-.5-12t2-10 4.5-9.5l6-9 7-8.5 8-9q7-8 31-34.5t34.5-38 31-39.5 32.5-51 27-59 26-76q-157-89-247.5-220t-90.5-281q0-174 120-321.5t326-233 450-85.5 450 85.5 326 233 120 321.5z"/></svg>
\ No newline at end of file diff --git a/app/views/shared/icons/_emoji_slightly_smiling_face.svg b/app/views/shared/icons/_emoji_slightly_smiling_face.svg new file mode 100644 index 00000000000..56dbad91554 --- /dev/null +++ b/app/views/shared/icons/_emoji_slightly_smiling_face.svg @@ -0,0 +1 @@ +<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><path d="M13.29 11.098a4.328 4.328 0 0 1-1.618 2.285c-.79.578-1.68.867-2.672.867-.992 0-1.883-.29-2.672-.867a4.328 4.328 0 0 1-1.617-2.285.721.721 0 0 1 .047-.569.715.715 0 0 1 .445-.369.721.721 0 0 1 .568.047.715.715 0 0 1 .37.445c.195.625.556 1.131 1.084 1.518A2.93 2.93 0 0 0 9 12.75a2.93 2.93 0 0 0 1.775-.58 2.913 2.913 0 0 0 1.084-1.518.711.711 0 0 1 .375-.445.737.737 0 0 1 .575-.047c.195.063.34.186.433.37.094.183.11.372.047.568zM7.5 6c0 .414-.146.768-.44 1.06-.292.294-.646.44-1.06.44-.414 0-.768-.146-1.06-.44A1.445 1.445 0 0 1 4.5 6c0-.414.146-.768.44-1.06.292-.294.646-.44 1.06-.44.414 0 .768.146 1.06.44.294.292.44.646.44 1.06zm6 0c0 .414-.146.768-.44 1.06-.292.294-.646.44-1.06.44-.414 0-.768-.146-1.06-.44A1.445 1.445 0 0 1 10.5 6c0-.414.146-.768.44-1.06.292-.294.646-.44 1.06-.44.414 0 .768.146 1.06.44.294.292.44.646.44 1.06zm3 3a7.29 7.29 0 0 0-.598-2.912 7.574 7.574 0 0 0-1.6-2.39 7.574 7.574 0 0 0-2.39-1.6A7.29 7.29 0 0 0 9 1.5a7.29 7.29 0 0 0-2.912.598 7.574 7.574 0 0 0-2.39 1.6 7.574 7.574 0 0 0-1.6 2.39A7.29 7.29 0 0 0 1.5 9c0 1.016.2 1.986.598 2.912a7.574 7.574 0 0 0 1.6 2.39 7.574 7.574 0 0 0 2.39 1.6A7.29 7.29 0 0 0 9 16.5a7.29 7.29 0 0 0 2.912-.598 7.574 7.574 0 0 0 2.39-1.6 7.574 7.574 0 0 0 1.6-2.39A7.29 7.29 0 0 0 16.5 9zM18 9a8.804 8.804 0 0 1-1.207 4.518 8.96 8.96 0 0 1-3.275 3.275A8.804 8.804 0 0 1 9 18a8.804 8.804 0 0 1-4.518-1.207 8.96 8.96 0 0 1-3.275-3.275A8.804 8.804 0 0 1 0 9c0-1.633.402-3.139 1.207-4.518a8.96 8.96 0 0 1 3.275-3.275A8.804 8.804 0 0 1 9 0c1.633 0 3.139.402 4.518 1.207a8.96 8.96 0 0 1 3.275 3.275A8.804 8.804 0 0 1 18 9z" fill-rule="evenodd"/></svg> diff --git a/app/views/shared/icons/_emoji_smile.svg b/app/views/shared/icons/_emoji_smile.svg new file mode 100644 index 00000000000..ce645fee46f --- /dev/null +++ b/app/views/shared/icons/_emoji_smile.svg @@ -0,0 +1 @@ +<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><path d="M13.29 11.098a4.328 4.328 0 0 1-1.618 2.285c-.79.578-1.68.867-2.672.867-.992 0-1.883-.29-2.672-.867a4.328 4.328 0 0 1-1.617-2.285.721.721 0 0 1 .047-.569.715.715 0 0 1 .445-.369c.195-.062 7.41-.062 7.606 0 .195.063.34.186.433.37.094.183.11.372.047.568zM14 6.37c0 .398-.04.755-.513.755-.473 0-.498-.272-1.237-.272-.74 0-.74.215-1.165.215-.425 0-.585-.3-.585-.698 0-.397.17-.736.513-1.017.341-.281.754-.422 1.237-.422.483 0 .896.14 1.237.422.342.28.513.62.513 1.017zm-6.5 0c0 .398-.04.755-.513.755-.473 0-.498-.272-1.237-.272-.74 0-.74.215-1.165.215-.425 0-.585-.3-.585-.698 0-.397.17-.736.513-1.017.341-.281.754-.422 1.237-.422.483 0 .896.14 1.237.422.342.28.513.62.513 1.017zm9 2.63a7.29 7.29 0 0 0-.598-2.912 7.574 7.574 0 0 0-1.6-2.39 7.574 7.574 0 0 0-2.39-1.6A7.29 7.29 0 0 0 9 1.5a7.29 7.29 0 0 0-2.912.598 7.574 7.574 0 0 0-2.39 1.6 7.574 7.574 0 0 0-1.6 2.39A7.29 7.29 0 0 0 1.5 9c0 1.016.2 1.986.598 2.912a7.574 7.574 0 0 0 1.6 2.39 7.574 7.574 0 0 0 2.39 1.6A7.29 7.29 0 0 0 9 16.5a7.29 7.29 0 0 0 2.912-.598 7.574 7.574 0 0 0 2.39-1.6 7.574 7.574 0 0 0 1.6-2.39A7.29 7.29 0 0 0 16.5 9zM18 9a8.804 8.804 0 0 1-1.207 4.518 8.96 8.96 0 0 1-3.275 3.275A8.804 8.804 0 0 1 9 18a8.804 8.804 0 0 1-4.518-1.207 8.96 8.96 0 0 1-3.275-3.275A8.804 8.804 0 0 1 0 9c0-1.633.402-3.139 1.207-4.518a8.96 8.96 0 0 1 3.275-3.275A8.804 8.804 0 0 1 9 0c1.633 0 3.139.402 4.518 1.207a8.96 8.96 0 0 1 3.275 3.275A8.804 8.804 0 0 1 18 9z" fill-rule="evenodd"/></svg> diff --git a/app/views/shared/icons/_emoji_smiley.svg b/app/views/shared/icons/_emoji_smiley.svg new file mode 100644 index 00000000000..ddfae50e566 --- /dev/null +++ b/app/views/shared/icons/_emoji_smiley.svg @@ -0,0 +1 @@ +<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><path d="M13.29 11.098a4.328 4.328 0 0 1-1.618 2.285c-.79.578-1.68.867-2.672.867-.992 0-1.883-.29-2.672-.867a4.328 4.328 0 0 1-1.617-2.285.721.721 0 0 1 .047-.569.715.715 0 0 1 .445-.369c.195-.062 7.41-.062 7.606 0 .195.063.34.186.433.37.094.183.11.372.047.568h.001zM7.5 6c0 .414-.146.768-.44 1.06A1.44 1.44 0 0 1 6 7.5a1.44 1.44 0 0 1-1.06-.44A1.445 1.445 0 0 1 4.5 6c0-.414.146-.768.44-1.06A1.44 1.44 0 0 1 6 4.5c.414 0 .768.146 1.06.44.294.292.44.646.44 1.06zm6 0c0 .414-.146.768-.44 1.06A1.44 1.44 0 0 1 12 7.5a1.44 1.44 0 0 1-1.06-.44A1.445 1.445 0 0 1 10.5 6c0-.414.146-.768.44-1.06A1.44 1.44 0 0 1 12 4.5c.414 0 .768.146 1.06.44.294.292.44.646.44 1.06zm3 3a7.29 7.29 0 0 0-.598-2.912 7.574 7.574 0 0 0-1.6-2.39 7.574 7.574 0 0 0-2.39-1.6A7.29 7.29 0 0 0 9 1.5a7.29 7.29 0 0 0-2.912.598 7.574 7.574 0 0 0-2.39 1.6 7.574 7.574 0 0 0-1.6 2.39A7.29 7.29 0 0 0 1.5 9c0 1.016.2 1.986.598 2.912a7.574 7.574 0 0 0 1.6 2.39 7.574 7.574 0 0 0 2.39 1.6c.92.397 1.91.6 2.912.598a7.29 7.29 0 0 0 2.912-.598 7.574 7.574 0 0 0 2.39-1.6 7.574 7.574 0 0 0 1.6-2.39c.397-.92.6-1.91.598-2.912zM18 9a8.804 8.804 0 0 1-1.207 4.518 8.96 8.96 0 0 1-3.275 3.275A8.804 8.804 0 0 1 9 18a8.804 8.804 0 0 1-4.518-1.207 8.96 8.96 0 0 1-3.275-3.275A8.804 8.804 0 0 1 0 9c0-1.633.402-3.139 1.207-4.518a8.96 8.96 0 0 1 3.275-3.275A8.804 8.804 0 0 1 9 0c1.633 0 3.139.402 4.518 1.207a8.96 8.96 0 0 1 3.275 3.275A8.804 8.804 0 0 1 18 9z" fill-rule="nonzero"/></svg> diff --git a/app/views/shared/icons/_icon_commit.svg b/app/views/shared/icons/_icon_commit.svg index 0e96035b7b7..15e83dfdb53 100644 --- a/app/views/shared/icons/_icon_commit.svg +++ b/app/views/shared/icons/_icon_commit.svg @@ -1,3 +1 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40"> - <path fill="#8F8F8F" fill-rule="evenodd" d="M28.7769836,18 C27.8675252,13.9920226 24.2831748,11 20,11 C15.7168252,11 12.1324748,13.9920226 11.2230164,18 L4.0085302,18 C2.90195036,18 2,18.8954305 2,20 C2,21.1122704 2.8992496,22 4.0085302,22 L11.2230164,22 C12.1324748,26.0079774 15.7168252,29 20,29 C24.2831748,29 27.8675252,26.0079774 28.7769836,22 L35.9914698,22 C37.0980496,22 38,21.1045695 38,20 C38,18.8877296 37.1007504,18 35.9914698,18 L28.7769836,18 L28.7769836,18 Z M20,25 C22.7614237,25 25,22.7614237 25,20 C25,17.2385763 22.7614237,15 20,15 C17.2385763,15 15,17.2385763 15,20 C15,22.7614237 17.2385763,25 20,25 L20,25 Z"/> -</svg> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 18" enable-background="new 0 0 36 18"><path d="m34 7h-7.2c-.9-4-4.5-7-8.8-7s-7.9 3-8.8 7h-7.2c-1.1 0-2 .9-2 2 0 1.1.9 2 2 2h7.2c.9 4 4.5 7 8.8 7s7.9-3 8.8-7h7.2c1.1 0 2-.9 2-2 0-1.1-.9-2-2-2m-16 7c-2.8 0-5-2.2-5-5s2.2-5 5-5 5 2.2 5 5-2.2 5-5 5"/></svg>
\ No newline at end of file diff --git a/app/views/shared/icons/_icon_status_closed.svg b/app/views/shared/icons/_icon_status_closed.svg new file mode 100644 index 00000000000..de448ee1194 --- /dev/null +++ b/app/views/shared/icons/_icon_status_closed.svg @@ -0,0 +1 @@ +<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><path d="M0 7c0-3.866 3.142-7 7-7 3.866 0 7 3.142 7 7 0 3.866-3.142 7-7 7-3.866 0-7-3.142-7-7z"/><path d="M1 7c0 3.309 2.69 6 6 6 3.309 0 6-2.69 6-6 0-3.309-2.69-6-6-6-3.309 0-6 2.69-6 6z" fill="#FFF"/><rect x="3.36" y="6.16" width="7.28" height="1.68" rx=".84"/></svg> diff --git a/app/views/shared/icons/_icon_status_open.svg b/app/views/shared/icons/_icon_status_open.svg new file mode 100644 index 00000000000..ed58d23c626 --- /dev/null +++ b/app/views/shared/icons/_icon_status_open.svg @@ -0,0 +1 @@ +<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><path d="M0 7c0-3.866 3.142-7 7-7 3.866 0 7 3.142 7 7 0 3.866-3.142 7-7 7-3.866 0-7-3.142-7-7z"/><path d="M1 7c0 3.309 2.69 6 6 6 3.309 0 6-2.69 6-6 0-3.309-2.69-6-6-6-3.309 0-6 2.69-6 6z" fill="#FFF"/><path d="M7 9.219a2.218 2.218 0 1 0 0-4.436A2.218 2.218 0 0 0 7 9.22zm0 1.12a3.338 3.338 0 1 1 0-6.676 3.338 3.338 0 0 1 0 6.676z"/></svg> diff --git a/app/views/shared/icons/_trash_o.svg b/app/views/shared/icons/_trash_o.svg new file mode 100644 index 00000000000..ea073d7fe67 --- /dev/null +++ b/app/views/shared/icons/_trash_o.svg @@ -0,0 +1 @@ +<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M704 736v576q0 14-9 23t-23 9h-64q-14 0-23-9t-9-23v-576q0-14 9-23t23-9h64q14 0 23 9t9 23zm256 0v576q0 14-9 23t-23 9h-64q-14 0-23-9t-9-23v-576q0-14 9-23t23-9h64q14 0 23 9t9 23zm256 0v576q0 14-9 23t-23 9h-64q-14 0-23-9t-9-23v-576q0-14 9-23t23-9h64q14 0 23 9t9 23zm128 724v-948h-896v948q0 22 7 40.5t14.5 27 10.5 8.5h832q3 0 10.5-8.5t14.5-27 7-40.5zm-672-1076h448l-48-117q-7-9-17-11h-317q-10 2-17 11zm928 32v64q0 14-9 23t-23 9h-96v948q0 83-47 143.5t-113 60.5h-832q-66 0-113-58.5t-47-141.5v-952h-96q-14 0-23-9t-9-23v-64q0-14 9-23t23-9h309l70-167q15-37 54-63t79-26h320q40 0 79 26t54 63l70 167h309q14 0 23 9t9 23z"/></svg>
\ No newline at end of file diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml index 847a86e2e68..c72268473ca 100644 --- a/app/views/shared/issuable/_filter.html.haml +++ b/app/views/shared/issuable/_filter.html.haml @@ -40,21 +40,21 @@ .issues_bulk_update.hide = form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: 'bulk-update' do .filter-item.inline - = dropdown_tag("Status", options: { toggle_class: "js-issue-status", title: "Change status", dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]", default_label: "Status" } } ) do + = dropdown_tag("Status", options: { toggle_class: "issue-bulk-update-dropdown-toggle js-issue-status", title: "Change status", dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]", default_label: "Status" } } ) do %ul %li %a{ href: "#", data: { id: "reopen" } } Open %li %a{ href: "#", data: {id: "close" } } Closed .filter-item.inline - = dropdown_tag("Assignee", options: { toggle_class: "js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable", + = dropdown_tag("Assignee", options: { toggle_class: "issue-bulk-update-dropdown-toggle js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable", placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: "update[assignee_id]", default_label: "Assignee" } }) .filter-item.inline - = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", default_label: "Milestone", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true } }) + = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'issue-bulk-update-dropdown-toggle js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", default_label: "Milestone", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true } }) .filter-item.inline.labels-filter = render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], dropdown_title: 'Apply a label', show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true } .filter-item.inline - = dropdown_tag("Subscription", options: { toggle_class: "js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]", default_label: "Subscription" } } ) do + = dropdown_tag("Subscription", options: { toggle_class: "issue-bulk-update-dropdown-toggle js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]", default_label: "Subscription" } } ) do %ul %li %a{ href: "#", data: { id: "subscribe" } } Subscribe diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 4c3c81a2f56..b447996a8ab 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -10,85 +10,93 @@ .check-all-holder = check_box_tag "check_all_issues", nil, false, class: "check_all_issues left" - .issues-other-filters.filtered-search-container - .filtered-search-input-container - .scroll-container - %ul.tokens-container.list-unstyled - %li.input-token - %input.form-control.filtered-search{ id: "filtered-search-#{type.to_s}", placeholder: 'Search or filter results...', data: { 'project-id' => @project.id, 'username-params' => @users.to_json(only: [:id, :username]), 'base-endpoint' => namespace_project_path(@project.namespace, @project) } } - = icon('filter') - %button.clear-search.hidden{ type: 'button' } - = icon('times') - #js-dropdown-hint.dropdown-menu.hint-dropdown - %ul{ data: { dropdown: true } } - %li.filter-dropdown-item{ data: { action: 'submit' } } - %button.btn.btn-link - = icon('search') - %span - Press Enter or click to search - %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - %li.filter-dropdown-item - %button.btn.btn-link - -# Encapsulate static class name `{{icon}}` inside #{} to bypass - -# haml lint's ClassAttributeWithStaticValue - %i.fa{ class: "#{'{{icon}}'}" } - %span.js-filter-hint - {{hint}} - %span.js-filter-tag.dropdown-light-content - {{tag}} - #js-dropdown-author.dropdown-menu{ data: { icon: 'pencil', hint: 'author', tag: '@author' } } - %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - %li.filter-dropdown-item - %button.btn.btn-link.dropdown-user - %img.avatar{ alt: '{{name}}\'s avatar', width: '30', data: { src: '{{avatar_url}}' } } - .dropdown-user-details + .issues-other-filters.filtered-search-wrapper + .filtered-search-box + = dropdown_tag(content_tag(:i, '', class: 'fa fa-history'), + options: { wrapper_class: "filtered-search-history-dropdown-wrapper", + toggle_class: "filtered-search-history-dropdown-toggle-button", + dropdown_class: "filtered-search-history-dropdown", + content_class: "filtered-search-history-dropdown-content", + title: "Recent searches" }) do + .js-filtered-search-history-dropdown + .filtered-search-box-input-container + .scroll-container + %ul.tokens-container.list-unstyled + %li.input-token + %input.form-control.filtered-search{ id: "filtered-search-#{type.to_s}", placeholder: 'Search or filter results...', data: { 'project-id' => @project.id, 'username-params' => @users.to_json(only: [:id, :username]), 'base-endpoint' => namespace_project_path(@project.namespace, @project) } } + = icon('filter') + %button.clear-search.hidden{ type: 'button' } + = icon('times') + #js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown + %ul{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { action: 'submit' } } + %button.btn.btn-link + = icon('search') %span - {{name}} - %span.dropdown-light-content - @{{username}} - #js-dropdown-assignee.dropdown-menu{ data: { icon: 'user', hint: 'assignee', tag: '@assignee' } } - %ul{ data: { dropdown: true } } - %li.filter-dropdown-item{ data: { value: 'none' } } - %button.btn.btn-link - No Assignee - %li.divider - %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - %li.filter-dropdown-item - %button.btn.btn-link.dropdown-user - %img.avatar{ alt: '{{name}}\'s avatar', width: '30', data: { src: '{{avatar_url}}' } } - .dropdown-user-details - %span - {{name}} - %span.dropdown-light-content - @{{username}} - #js-dropdown-milestone.dropdown-menu{ data: { icon: 'clock-o', hint: 'milestone', tag: '%milestone' } } - %ul{ data: { dropdown: true } } - %li.filter-dropdown-item{ data: { value: 'none' } } - %button.btn.btn-link - No Milestone - %li.filter-dropdown-item{ data: { value: 'upcoming' } } - %button.btn.btn-link - Upcoming - %li.filter-dropdown-item{ 'data-value' => 'started' } - %button.btn.btn-link - Started - %li.divider - %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - %li.filter-dropdown-item - %button.btn.btn-link.js-data-value - {{title}} - #js-dropdown-label.dropdown-menu{ data: { icon: 'tag', hint: 'label', tag: '~label', type: 'array' } } - %ul{ data: { dropdown: true } } - %li.filter-dropdown-item{ data: { value: 'none' } } - %button.btn.btn-link - No Label - %li.divider - %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - %li.filter-dropdown-item - %button.btn.btn-link - %span.dropdown-label-box{ style: 'background: {{color}}' } - %span.label-title.js-data-value + Press Enter or click to search + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item + %button.btn.btn-link + -# Encapsulate static class name `{{icon}}` inside #{} to bypass + -# haml lint's ClassAttributeWithStaticValue + %i.fa{ class: "#{'{{icon}}'}" } + %span.js-filter-hint + {{hint}} + %span.js-filter-tag.dropdown-light-content + {{tag}} + #js-dropdown-author.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'pencil', hint: 'author', tag: '@author' } } + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item + %button.btn.btn-link.dropdown-user + %img.avatar{ alt: '{{name}}\'s avatar', width: '30', data: { src: '{{avatar_url}}' } } + .dropdown-user-details + %span + {{name}} + %span.dropdown-light-content + @{{username}} + #js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'user', hint: 'assignee', tag: '@assignee' } } + %ul{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { value: 'none' } } + %button.btn.btn-link + No Assignee + %li.divider + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item + %button.btn.btn-link.dropdown-user + %img.avatar{ alt: '{{name}}\'s avatar', width: '30', data: { src: '{{avatar_url}}' } } + .dropdown-user-details + %span + {{name}} + %span.dropdown-light-content + @{{username}} + #js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'clock-o', hint: 'milestone', tag: '%milestone' } } + %ul{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { value: 'none' } } + %button.btn.btn-link + No Milestone + %li.filter-dropdown-item{ data: { value: 'upcoming' } } + %button.btn.btn-link + Upcoming + %li.filter-dropdown-item{ 'data-value' => 'started' } + %button.btn.btn-link + Started + %li.divider + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item + %button.btn.btn-link.js-data-value {{title}} + #js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'tag', hint: 'label', tag: '~label', type: 'array' } } + %ul{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { value: 'none' } } + %button.btn.btn-link + No Label + %li.divider + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item + %button.btn.btn-link + %span.dropdown-label-box{ style: 'background: {{color}}' } + %span.label-title.js-data-value + {{title}} .filter-dropdown-container - if type == :boards - if can?(current_user, :admin_list, @project) diff --git a/app/workers/trigger_schedule_worker.rb b/app/workers/trigger_schedule_worker.rb new file mode 100644 index 00000000000..440c579b99d --- /dev/null +++ b/app/workers/trigger_schedule_worker.rb @@ -0,0 +1,18 @@ +class TriggerScheduleWorker + include Sidekiq::Worker + include CronjobQueue + + def perform + Ci::TriggerSchedule.where("next_run_at < ?", Time.now).find_each do |trigger_schedule| + begin + Ci::CreateTriggerRequestService.new.execute(trigger_schedule.project, + trigger_schedule.trigger, + trigger_schedule.ref) + rescue => e + Rails.logger.error "#{trigger_schedule.id}: Failed to trigger_schedule job: #{e.message}" + ensure + trigger_schedule.schedule_next_run! + end + end + end +end diff --git a/changelogs/unreleased/27262-issue-recent-searches.yml b/changelogs/unreleased/27262-issue-recent-searches.yml new file mode 100644 index 00000000000..4bdec5af31d --- /dev/null +++ b/changelogs/unreleased/27262-issue-recent-searches.yml @@ -0,0 +1,4 @@ +--- +title: Recent search history for issues +merge_request: +author: diff --git a/changelogs/unreleased/28695-move-all-associated-records-to-ghost-user.yml b/changelogs/unreleased/28695-move-all-associated-records-to-ghost-user.yml new file mode 100644 index 00000000000..c5dcde48028 --- /dev/null +++ b/changelogs/unreleased/28695-move-all-associated-records-to-ghost-user.yml @@ -0,0 +1,4 @@ +--- +title: Deleting a user should not delete associated records +merge_request: 10467 +author: diff --git a/changelogs/unreleased/28899-linking-to-edit-file.yml b/changelogs/unreleased/28899-linking-to-edit-file.yml new file mode 100644 index 00000000000..a9f5410693b --- /dev/null +++ b/changelogs/unreleased/28899-linking-to-edit-file.yml @@ -0,0 +1,5 @@ +--- +title: Linking to blob edit page handles anonymous users and users without enough permissions + to edit directly +merge_request: +author: diff --git a/changelogs/unreleased/29128-profile-page-icons.yml b/changelogs/unreleased/29128-profile-page-icons.yml new file mode 100644 index 00000000000..0215f5c0e8f --- /dev/null +++ b/changelogs/unreleased/29128-profile-page-icons.yml @@ -0,0 +1,4 @@ +--- +title: Add helpful icons to profile events +merge_request: +author: diff --git a/changelogs/unreleased/2989-run-cicd-pipelines-on-a-schedule-idea1-basic-backend-implementation.yml b/changelogs/unreleased/2989-run-cicd-pipelines-on-a-schedule-idea1-basic-backend-implementation.yml new file mode 100644 index 00000000000..dd56409c35b --- /dev/null +++ b/changelogs/unreleased/2989-run-cicd-pipelines-on-a-schedule-idea1-basic-backend-implementation.yml @@ -0,0 +1,4 @@ +--- +title: Resolve "Run CI/CD pipelines on a schedule" - "Basic backend implementation" +merge_request: 10133 +author: dosuken123 diff --git a/changelogs/unreleased/30024-owner-can-t-initialize-git-repo-for-new-project-in-group.yml b/changelogs/unreleased/30024-owner-can-t-initialize-git-repo-for-new-project-in-group.yml new file mode 100644 index 00000000000..c43d2732b9a --- /dev/null +++ b/changelogs/unreleased/30024-owner-can-t-initialize-git-repo-for-new-project-in-group.yml @@ -0,0 +1,4 @@ +--- +title: Disable invalid service templates +merge_request: +author: diff --git a/changelogs/unreleased/add-dimension-etag-caching-metrics.yml b/changelogs/unreleased/add-dimension-etag-caching-metrics.yml new file mode 100644 index 00000000000..f2a13eb7c61 --- /dev/null +++ b/changelogs/unreleased/add-dimension-etag-caching-metrics.yml @@ -0,0 +1,4 @@ +--- +title: Include endpoint in metrics for ETag caching middleware +merge_request: 10495 +author: diff --git a/changelogs/unreleased/add-error-empty-states.yml b/changelogs/unreleased/add-error-empty-states.yml new file mode 100644 index 00000000000..ec6c7b6dce9 --- /dev/null +++ b/changelogs/unreleased/add-error-empty-states.yml @@ -0,0 +1,4 @@ +--- +title: Introduced error/empty states for the environments performance metrics +merge_request: 10271 +author: diff --git a/changelogs/unreleased/award-emoji-button-smiley-animation.yml b/changelogs/unreleased/award-emoji-button-smiley-animation.yml new file mode 100644 index 00000000000..31903aeb040 --- /dev/null +++ b/changelogs/unreleased/award-emoji-button-smiley-animation.yml @@ -0,0 +1,4 @@ +--- +title: Added award emoji animation and improved active state +merge_request: +author: diff --git a/changelogs/unreleased/feature-enforce-2fa-per-group.yml b/changelogs/unreleased/feature-enforce-2fa-per-group.yml new file mode 100644 index 00000000000..6dd99e4245f --- /dev/null +++ b/changelogs/unreleased/feature-enforce-2fa-per-group.yml @@ -0,0 +1,4 @@ +--- +title: Support 2FA requirement per-group +merge_request: 8763 +author: Markus Koller diff --git a/changelogs/unreleased/feature-gh-rake-task.yml b/changelogs/unreleased/feature-gh-rake-task.yml new file mode 100644 index 00000000000..5b1d380690c --- /dev/null +++ b/changelogs/unreleased/feature-gh-rake-task.yml @@ -0,0 +1,4 @@ +--- +title: Add rake task to import GitHub projects from the command line +merge_request: +author: diff --git a/changelogs/unreleased/feature-multi-level-container-registry-images.yml b/changelogs/unreleased/feature-multi-level-container-registry-images.yml new file mode 100644 index 00000000000..6d39a6c17c0 --- /dev/null +++ b/changelogs/unreleased/feature-multi-level-container-registry-images.yml @@ -0,0 +1,4 @@ +--- +title: Add support for multi-level container image repository names +merge_request: 10109 +author: André Guede diff --git a/changelogs/unreleased/microsoft-teams-integration.yml b/changelogs/unreleased/microsoft-teams-integration.yml new file mode 100644 index 00000000000..c01902d3401 --- /dev/null +++ b/changelogs/unreleased/microsoft-teams-integration.yml @@ -0,0 +1,4 @@ +--- +title: Integrates Microsoft Teams webhooks with GitLab +merge_request: 10412 +author: diff --git a/changelogs/unreleased/new-resolvable-discussion.yml b/changelogs/unreleased/new-resolvable-discussion.yml new file mode 100644 index 00000000000..f4dc4ea3ede --- /dev/null +++ b/changelogs/unreleased/new-resolvable-discussion.yml @@ -0,0 +1,4 @@ +--- +title: Add option to start a new resolvable discussion in an MR +merge_request: 7527 +author: diff --git a/changelogs/unreleased/tc-show-pipeline-coverage-if-avail.yml b/changelogs/unreleased/tc-show-pipeline-coverage-if-avail.yml new file mode 100644 index 00000000000..c0cc4fb18c8 --- /dev/null +++ b/changelogs/unreleased/tc-show-pipeline-coverage-if-avail.yml @@ -0,0 +1,4 @@ +--- +title: Show the build/pipeline coverage if it is available +merge_request: +author: diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 4314e902564..3c70f35b9d0 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -180,6 +180,9 @@ production: &base # Flag stuck CI jobs as failed stuck_ci_jobs_worker: cron: "0 * * * *" + # Execute scheduled triggers + trigger_schedule_worker: + cron: "0 */12 * * *" # Remove expired build artifacts expire_build_artifacts_worker: cron: "50 * * * *" diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index f7cae84088e..4c9d829aa9f 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -315,6 +315,9 @@ Settings['cron_jobs'] ||= Settingslogic.new({}) Settings.cron_jobs['stuck_ci_jobs_worker'] ||= Settingslogic.new({}) Settings.cron_jobs['stuck_ci_jobs_worker']['cron'] ||= '0 * * * *' Settings.cron_jobs['stuck_ci_jobs_worker']['job_class'] = 'StuckCiJobsWorker' +Settings.cron_jobs['trigger_schedule_worker'] ||= Settingslogic.new({}) +Settings.cron_jobs['trigger_schedule_worker']['cron'] ||= '0 */12 * * *' +Settings.cron_jobs['trigger_schedule_worker']['job_class'] = 'TriggerScheduleWorker' 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' diff --git a/config/routes/project.rb b/config/routes/project.rb index 62e2e6145fd..e38f0537143 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -221,7 +221,15 @@ constraints(ProjectUrlConstrainer.new) do end end - resources :container_registry, only: [:index, :destroy], constraints: { id: Gitlab::Regex.container_registry_reference_regex } + resources :container_registry, only: [:index, :destroy], + controller: 'registry/repositories' + + namespace :registry do + resources :repository, only: [] do + resources :tags, only: [:destroy], + constraints: { id: Gitlab::Regex.container_registry_reference_regex } + end + end resources :milestones, constraints: { id: /\d+/ } do member do diff --git a/db/fixtures/development/14_pipelines.rb b/db/fixtures/development/14_pipelines.rb index 534847a7107..3c42f7db6d5 100644 --- a/db/fixtures/development/14_pipelines.rb +++ b/db/fixtures/development/14_pipelines.rb @@ -130,7 +130,7 @@ class Gitlab::Seeder::Pipelines def setup_build_log(build) if %w(running success failed).include?(build.status) - build.trace = FFaker::Lorem.paragraphs(6).join("\n\n") + build.trace.set(FFaker::Lorem.paragraphs(6).join("\n\n")) end end diff --git a/db/migrate/20161128095517_add_in_reply_to_discussion_id_to_sent_notifications.rb b/db/migrate/20161128095517_add_in_reply_to_discussion_id_to_sent_notifications.rb new file mode 100644 index 00000000000..d56d83ca1d3 --- /dev/null +++ b/db/migrate/20161128095517_add_in_reply_to_discussion_id_to_sent_notifications.rb @@ -0,0 +1,29 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddInReplyToDiscussionIdToSentNotifications < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + # DOWNTIME_REASON = '' + + # When using the methods "add_concurrent_index" or "add_column_with_default" + # you must disable the use of transactions as these methods can not run in an + # existing transaction. When using "add_concurrent_index" make sure that this + # method is the _only_ method called in the migration, any other changes + # should go in a separate migration. This ensures that upon failure _only_ the + # index creation fails and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + # disable_ddl_transaction! + + def change + add_column :sent_notifications, :in_reply_to_discussion_id, :string + end +end diff --git a/db/migrate/20170124193147_add_two_factor_columns_to_namespaces.rb b/db/migrate/20170124193147_add_two_factor_columns_to_namespaces.rb new file mode 100644 index 00000000000..df5cddeb205 --- /dev/null +++ b/db/migrate/20170124193147_add_two_factor_columns_to_namespaces.rb @@ -0,0 +1,21 @@ +class AddTwoFactorColumnsToNamespaces < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column_with_default(:namespaces, :require_two_factor_authentication, :boolean, default: false) + add_column_with_default(:namespaces, :two_factor_grace_period, :integer, default: 48) + + add_concurrent_index(:namespaces, :require_two_factor_authentication) + end + + def down + remove_column(:namespaces, :require_two_factor_authentication) + remove_column(:namespaces, :two_factor_grace_period) + + remove_concurrent_index(:namespaces, :require_two_factor_authentication) if index_exists?(:namespaces, :require_two_factor_authentication) + end +end diff --git a/db/migrate/20170124193205_add_two_factor_columns_to_users.rb b/db/migrate/20170124193205_add_two_factor_columns_to_users.rb new file mode 100644 index 00000000000..1d1021fcbb3 --- /dev/null +++ b/db/migrate/20170124193205_add_two_factor_columns_to_users.rb @@ -0,0 +1,17 @@ +class AddTwoFactorColumnsToUsers < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column_with_default(:users, :require_two_factor_authentication_from_group, :boolean, default: false) + add_column_with_default(:users, :two_factor_grace_period, :integer, default: 48) + end + + def down + remove_column(:users, :require_two_factor_authentication_from_group) + remove_column(:users, :two_factor_grace_period) + end +end diff --git a/db/migrate/20170322013926_create_container_repository.rb b/db/migrate/20170322013926_create_container_repository.rb new file mode 100644 index 00000000000..91540bc88bd --- /dev/null +++ b/db/migrate/20170322013926_create_container_repository.rb @@ -0,0 +1,16 @@ +class CreateContainerRepository < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + create_table :container_repositories do |t| + t.references :project, foreign_key: true, index: true, null: false + t.string :name, null: false + + t.timestamps null: false + end + + add_index :container_repositories, [:project_id, :name], unique: true + end +end diff --git a/db/migrate/20170329095325_add_ref_to_triggers.rb b/db/migrate/20170329095325_add_ref_to_triggers.rb new file mode 100644 index 00000000000..4aa52dd8f8f --- /dev/null +++ b/db/migrate/20170329095325_add_ref_to_triggers.rb @@ -0,0 +1,9 @@ +class AddRefToTriggers < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :ci_triggers, :ref, :string + end +end diff --git a/db/migrate/20170329095907_create_ci_trigger_schedules.rb b/db/migrate/20170329095907_create_ci_trigger_schedules.rb new file mode 100644 index 00000000000..cfcfa27ebb5 --- /dev/null +++ b/db/migrate/20170329095907_create_ci_trigger_schedules.rb @@ -0,0 +1,21 @@ +class CreateCiTriggerSchedules < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + create_table :ci_trigger_schedules do |t| + t.integer "project_id" + t.integer "trigger_id", null: false + t.datetime "deleted_at" + t.datetime "created_at" + t.datetime "updated_at" + t.string "cron" + t.string "cron_timezone" + t.datetime "next_run_at" + end + + add_index :ci_trigger_schedules, :next_run_at + add_index :ci_trigger_schedules, :project_id + end +end diff --git a/db/migrate/20170330141723_disable_invalid_service_templates2.rb b/db/migrate/20170330141723_disable_invalid_service_templates2.rb new file mode 100644 index 00000000000..8424e56d8a1 --- /dev/null +++ b/db/migrate/20170330141723_disable_invalid_service_templates2.rb @@ -0,0 +1,18 @@ +# This is the same as DisableInvalidServiceTemplates. Later migrations may have +# inadventently enabled some invalid templates again. +# +class DisableInvalidServiceTemplates2 < ActiveRecord::Migration + DOWNTIME = false + + unless defined?(Service) + class Service < ActiveRecord::Base + self.inheritance_column = nil + end + end + + def up + Service.where(template: true, active: true).each do |template| + template.update(active: false) unless template.valid? + end + end +end diff --git a/db/migrate/20170402231018_remove_index_for_users_current_sign_in_at.rb b/db/migrate/20170402231018_remove_index_for_users_current_sign_in_at.rb index 8316ee9eb9f..0237c3189a5 100644 --- a/db/migrate/20170402231018_remove_index_for_users_current_sign_in_at.rb +++ b/db/migrate/20170402231018_remove_index_for_users_current_sign_in_at.rb @@ -1,6 +1,7 @@ # See http://doc.gitlab.com/ce/development/migration_style_guide.html # for more information on how to write migrations for GitLab. +# rubocop:disable RemoveIndex class RemoveIndexForUsersCurrentSignInAt < ActiveRecord::Migration include Gitlab::Database::MigrationHelpers diff --git a/db/migrate/20170404163427_add_trigger_id_foreign_key.rb b/db/migrate/20170404163427_add_trigger_id_foreign_key.rb new file mode 100644 index 00000000000..6679a95ca11 --- /dev/null +++ b/db/migrate/20170404163427_add_trigger_id_foreign_key.rb @@ -0,0 +1,15 @@ +class AddTriggerIdForeignKey < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_foreign_key :ci_trigger_schedules, :ci_triggers, column: :trigger_id, on_delete: :cascade + end + + def down + remove_foreign_key :ci_trigger_schedules, column: :trigger_id + end +end diff --git a/db/post_migrate/20170404170532_remove_notes_original_discussion_id.rb b/db/post_migrate/20170404170532_remove_notes_original_discussion_id.rb new file mode 100644 index 00000000000..0c3b3bd5eb3 --- /dev/null +++ b/db/post_migrate/20170404170532_remove_notes_original_discussion_id.rb @@ -0,0 +1,23 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class RemoveNotesOriginalDiscussionId < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + # When using the methods "add_concurrent_index" or "add_column_with_default" + # you must disable the use of transactions as these methods can not run in an + # existing transaction. When using "add_concurrent_index" make sure that this + # method is the _only_ method called in the migration, any other changes + # should go in a separate migration. This ensures that upon failure _only_ the + # index creation fails and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + # disable_ddl_transaction! + + def change + remove_column :notes, :original_discussion_id, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 582f68cbee7..f1783ab1b05 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -12,7 +12,6 @@ # It's strongly recommended that you check this file into your version control system. ActiveRecord::Schema.define(version: 20170405080720) do - # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" enable_extension "pg_trgm" @@ -300,6 +299,20 @@ ActiveRecord::Schema.define(version: 20170405080720) do add_index "ci_trigger_requests", ["commit_id"], name: "index_ci_trigger_requests_on_commit_id", using: :btree + create_table "ci_trigger_schedules", force: :cascade do |t| + t.integer "project_id" + t.integer "trigger_id", null: false + t.datetime "deleted_at" + t.datetime "created_at" + t.datetime "updated_at" + t.string "cron" + t.string "cron_timezone" + t.datetime "next_run_at" + end + + add_index "ci_trigger_schedules", ["next_run_at"], name: "index_ci_trigger_schedules_on_next_run_at", using: :btree + add_index "ci_trigger_schedules", ["project_id"], name: "index_ci_trigger_schedules_on_project_id", using: :btree + create_table "ci_triggers", force: :cascade do |t| t.string "token" t.datetime "deleted_at" @@ -308,6 +321,7 @@ ActiveRecord::Schema.define(version: 20170405080720) do t.integer "project_id" t.integer "owner_id" t.string "description" + t.string "ref" end add_index "ci_triggers", ["project_id"], name: "index_ci_triggers_on_project_id", using: :btree @@ -323,6 +337,16 @@ ActiveRecord::Schema.define(version: 20170405080720) do add_index "ci_variables", ["project_id"], name: "index_ci_variables_on_project_id", using: :btree + create_table "container_repositories", force: :cascade do |t| + t.integer "project_id", null: false + t.string "name", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "container_repositories", ["project_id", "name"], name: "index_container_repositories_on_project_id_and_name", unique: true, using: :btree + add_index "container_repositories", ["project_id"], name: "index_container_repositories_on_project_id", using: :btree + create_table "deploy_keys_projects", force: :cascade do |t| t.integer "deploy_key_id", null: false t.integer "project_id", null: false @@ -692,6 +716,8 @@ ActiveRecord::Schema.define(version: 20170405080720) do t.text "description_html" t.boolean "lfs_enabled" t.integer "parent_id" + t.boolean "require_two_factor_authentication", default: false, null: false + t.integer "two_factor_grace_period", default: 48, null: false end add_index "namespaces", ["created_at"], name: "index_namespaces_on_created_at", using: :btree @@ -702,6 +728,7 @@ ActiveRecord::Schema.define(version: 20170405080720) do add_index "namespaces", ["parent_id", "id"], name: "index_namespaces_on_parent_id_and_id", unique: true, using: :btree add_index "namespaces", ["path"], name: "index_namespaces_on_path", using: :btree add_index "namespaces", ["path"], name: "index_namespaces_on_path_trigram", using: :gin, opclasses: {"path"=>"gin_trgm_ops"} + add_index "namespaces", ["require_two_factor_authentication"], name: "index_namespaces_on_require_two_factor_authentication", using: :btree add_index "namespaces", ["type"], name: "index_namespaces_on_type", using: :btree create_table "notes", force: :cascade do |t| @@ -724,7 +751,6 @@ ActiveRecord::Schema.define(version: 20170405080720) do t.datetime "resolved_at" t.integer "resolved_by_id" t.string "discussion_id" - t.string "original_discussion_id" t.text "note_html" end @@ -1000,6 +1026,7 @@ ActiveRecord::Schema.define(version: 20170405080720) do t.string "line_code" t.string "note_type" t.text "position" + t.string "in_reply_to_discussion_id" end add_index "sent_notifications", ["reply_key"], name: "index_sent_notifications_on_reply_key", unique: true, using: :btree @@ -1247,6 +1274,8 @@ ActiveRecord::Schema.define(version: 20170405080720) do t.boolean "authorized_projects_populated" t.boolean "ghost" t.boolean "notified_of_own_activity" + t.boolean "require_two_factor_authentication_from_group", default: false, null: false + t.integer "two_factor_grace_period", default: 48, null: false end add_index "users", ["admin"], name: "index_users_on_admin", using: :btree @@ -1298,7 +1327,9 @@ ActiveRecord::Schema.define(version: 20170405080720) do add_foreign_key "boards", "projects" add_foreign_key "chat_teams", "namespaces", on_delete: :cascade + add_foreign_key "ci_trigger_schedules", "ci_triggers", column: "trigger_id", name: "fk_90a406cc94", on_delete: :cascade add_foreign_key "ci_triggers", "users", column: "owner_id", name: "fk_e8e10d1964", on_delete: :cascade + add_foreign_key "container_repositories", "projects" add_foreign_key "issue_metrics", "issues", on_delete: :cascade add_foreign_key "label_priorities", "labels", on_delete: :cascade add_foreign_key "label_priorities", "projects", on_delete: :cascade diff --git a/doc/administration/gitaly/index.md b/doc/administration/gitaly/index.md index 30a4c08508d..2e22212ddde 100644 --- a/doc/administration/gitaly/index.md +++ b/doc/administration/gitaly/index.md @@ -2,7 +2,7 @@ [Gitaly](https://gitlab.com/gitlab-org/gitlay) (introduced in GitLab 9.0) is a service that provides high-level RPC access to Git -repositories. As of GitLab 9.0 it is still an optional component with +repositories. As of GitLab 9.1 it is still an optional component with limited scope. GitLab components that access Git repositories (gitlab-rails, @@ -11,28 +11,26 @@ not have direct access to Gitaly. ## Configuring Gitaly -The Gitaly service itself is configured via environment variables. -These variables are documented [in the gitaly +The Gitaly service itself is configured via a TOML configuration file. +This file is documented [in the gitaly repository](https://gitlab.com/gitlab-org/gitaly/blob/master/doc/configuration/README.md). -To change a Gitaly environment variable in Omnibus you can use -`gitaly['env']` in `/etc/gitlab/gitlab.rb`. Changes will be applied +To change a Gitaly setting in Omnibus you can use +`gitaly['my_setting']` in `/etc/gitlab/gitlab.rb`. Changes will be applied when you run `gitlab-ctl reconfigure`. ```ruby -gitaly['env'] = { - 'GITALY_MY_VARIABLE' => 'value' -} +gitaly['prometheus_listen_addr'] = 'localhost:9236' ``` -To change a Gitaly environment variable in installations from source -you can edit `/home/git/gitaly/env`. +To change a Gitaly setting in installations from source you can edit +`/home/git/gitaly/config.toml`. -```shell -GITALY_MY_VARIABLE='value' +```toml +prometheus_listen_addr = "localhost:9236" ``` -Changes to `/home/git/gitaly/env` are applied when you run `service +Changes to `/home/git/gitaly/config.toml` are applied when you run `service gitlab restart`. ## Configuring GitLab to not use Gitaly @@ -49,15 +47,15 @@ gitaly['enable'] = false ``` In source installations, edit `/home/git/gitlab/config/gitlab.yml` and -make sure `socket_path` in the `gitaly` section is commented out. This -does not disable the Gitaly service; it only prevents it from being -used. +make sure `enabled` in the `gitaly` section is set to 'false'. This +does not disable the Gitaly service in your init script; it only +prevents it from being used. Apply the change with `service gitlab restart`. ```yaml gitaly: - # socket_path: tmp/sockets/private/gitlay.socket + enabled: false ``` ## Disabling or enabling the Gitaly service diff --git a/doc/administration/raketasks/github_import.md b/doc/administration/raketasks/github_import.md new file mode 100644 index 00000000000..affb4d17861 --- /dev/null +++ b/doc/administration/raketasks/github_import.md @@ -0,0 +1,36 @@ +# GitHub import + +>**Note:** +> +> - [Introduced][ce-10308] in GitLab 9.1. +> - You need a personal access token in order to retrieve and import GitHub +> projects. You can get it from: https://github.com/settings/tokens +> - You also need to pass an username as the second argument to the rake task +> which will become the owner of the project. + +To import a project from the list of your GitHub projects available: + +```bash +# Omnibus installations +sudo gitlab-rake import:github[access_token,root,foo/bar] + +# Installations from source +bundle exec rake import:github[access_token,root,foo/bar] RAILS_ENV=production +``` + +In this case, `access_token` is your GitHub personal access token, `root` +is your GitLab username, and `foo/bar` is the new GitLab namespace/project that +will get created from your GitHub project. Subgroups are also possible: `foo/foo/bar`. + + +To import a specific GitHub project (named `foo/github_repo` here): + +```bash +# Omnibus installations +sudo gitlab-rake import:github[access_token,root,foo/bar,foo/github_repo] + +# Installations from source +bundle exec rake import:github[access_token,root,foo/bar,foo/github_repo] RAILS_ENV=production +``` + +[ce-10308]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10308 diff --git a/doc/ci/docker/using_docker_build.md b/doc/ci/docker/using_docker_build.md index edb315d5b84..ffa0831290a 100644 --- a/doc/ci/docker/using_docker_build.md +++ b/doc/ci/docker/using_docker_build.md @@ -299,8 +299,8 @@ could look like: stage: build script: - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN registry.example.com - - docker build -t registry.example.com/group/project:latest . - - docker push registry.example.com/group/project:latest + - docker build -t registry.example.com/group/project/image:latest . + - docker push registry.example.com/group/project/image:latest ``` You have to use the special `gitlab-ci-token` user created for you in order to @@ -350,8 +350,8 @@ stages: - deploy variables: - CONTAINER_TEST_IMAGE: registry.example.com/my-group/my-project:$CI_COMMIT_REF_NAME - CONTAINER_RELEASE_IMAGE: registry.example.com/my-group/my-project:latest + CONTAINER_TEST_IMAGE: registry.example.com/my-group/my-project/my-image:$CI_COMMIT_REF_NAME + CONTAINER_RELEASE_IMAGE: registry.example.com/my-group/my-project/my-image:latest before_script: - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN registry.example.com diff --git a/doc/development/polling.md b/doc/development/polling.md index 4042b8aaa61..05e19f0c515 100644 --- a/doc/development/polling.md +++ b/doc/development/polling.md @@ -22,7 +22,12 @@ Instead you should use polling mechanism with ETag caching in Redis. ## How it works +Cache Miss: + ![Cache miss](img/cache-miss.svg) + +Cache Hit: + ![Cache hit](img/cache-hit.svg) 1. Whenever a resource changes we generate a random value and store it in diff --git a/doc/install/installation.md b/doc/install/installation.md index a2248a38435..5b72c2cce07 100644 --- a/doc/install/installation.md +++ b/doc/install/installation.md @@ -459,9 +459,9 @@ Make GitLab start on boot: ### Install Gitaly -As of GitLab 9.0 Gitaly is an **optional** component. Its -configuration is expected to change in GitLab 9.1. It is OK to wait -with setting up Gitaly until you upgrade to GitLab 9.1 or later. +As of GitLab 9.1 Gitaly is an **optional** component. Its +configuration is still changing regularly. It is OK to wait +with setting up Gitaly until you upgrade to GitLab 9.2 or later. # Fetch Gitaly source with Git and compile with Go sudo -u git -H bundle exec rake "gitlab:gitaly:install[/home/git/gitaly]" RAILS_ENV=production @@ -471,9 +471,11 @@ with setting up Gitaly until you upgrade to GitLab 9.1 or later. sudo chown git /home/git/gitlab/tmp/sockets/private # Configure Gitaly - echo 'GITALY_SOCKET_PATH=/home/git/gitlab/tmp/sockets/private/gitaly.socket' | \ - sudo -u git tee -a /home/git/gitaly/env - + cd /home/git/gitaly + sudo -u git cp config.toml.example config.toml + # If you are using non-default settings you need to update config.toml + sudo -u git -H editor config.toml + # Enable Gitaly in the init script echo 'gitaly_enabled=true' | sudo tee -a /etc/default/gitlab diff --git a/doc/profile/README.md b/doc/profile/README.md index 54e44d65959..aed64ac1228 100644 --- a/doc/profile/README.md +++ b/doc/profile/README.md @@ -2,3 +2,4 @@ - [Preferences](../user/profile/preferences.md) - [Two-factor Authentication (2FA)](../user/profile/account/two_factor_authentication.md) +- [Deleting your account](../user/profile/account/delete_account.md) diff --git a/doc/security/img/two_factor_authentication_group_settings.png b/doc/security/img/two_factor_authentication_group_settings.png Binary files differnew file mode 100644 index 00000000000..a1b3c58bfdc --- /dev/null +++ b/doc/security/img/two_factor_authentication_group_settings.png diff --git a/doc/security/two_factor_authentication.md b/doc/security/two_factor_authentication.md index c8499380c18..f02f7b807cf 100644 --- a/doc/security/two_factor_authentication.md +++ b/doc/security/two_factor_authentication.md @@ -8,7 +8,7 @@ their phone. You can read more about it here: [Two-factor Authentication (2FA)](../profile/two_factor_authentication.md) -## Enabling 2FA +## Enforcing 2FA for all users Users on GitLab, can enable it without any admin's intervention. If you want to enforce everyone to setup 2FA, you can choose from two different ways: @@ -28,6 +28,21 @@ period to `0`. --- +## Enforcing 2FA for all users in a group + +If you want to enforce 2FA only for certain groups, you can enable it in the +group settings and specify a grace period as above. To change this setting you +need to be administrator or owner of the group. + +If there are multiple 2FA requirements (i.e. group + all users, or multiple +groups) the shortest grace period will be used. + +--- + +![Two factor authentication group settings](img/two_factor_authentication_group_settings.png) + +--- + ## Disabling 2FA for everyone There may be some special situations where you want to disable 2FA for everyone diff --git a/doc/update/9.0-to-9.1.md b/doc/update/9.0-to-9.1.md index 53cddb3f290..ae983dea384 100644 --- a/doc/update/9.0-to-9.1.md +++ b/doc/update/9.0-to-9.1.md @@ -297,7 +297,10 @@ during your 9.1 upgrade **you can skip this step**. If you have not yet set up Gitaly then follow [Gitaly section of the installation guide](../install/installation.md#install-gitaly). -If you installed Gitaly in GitLab 9.0 you need to make some changes in gitlab.yml. +If you installed Gitaly in GitLab 9.0 you need to make some changes in +gitlab.yml, and create a new config.toml file. + +#### Gitaly gitlab.yml changes Look for `socket_path:` the `gitaly:` section. Its value is usually `/home/git/gitlab/tmp/sockets/private/gitaly.socket`. Note what socket @@ -318,6 +321,20 @@ the socket path, but with `unix:` in front. Each entry under `storages:` should use the same `gitaly_address`. +#### Gitaly config.toml + +In GitLab 9.1 we are replacing environment variables in Gitaly with a +TOML configuration file. + +```shell +cd /home/git/gitaly + +sudo mv env env.old +sudo -u git cp config.toml.example config.toml +# If you are using custom repository storage paths they need to be in config.toml +sudo -u git -H editor config.toml +``` + ### 11. Start application ```bash diff --git a/doc/user/profile/account/delete_account.md b/doc/user/profile/account/delete_account.md new file mode 100644 index 00000000000..505248536c8 --- /dev/null +++ b/doc/user/profile/account/delete_account.md @@ -0,0 +1,25 @@ +# Deleting a User Account + +- As a user, you can delete your own account by navigating to **Settings** > **Account** and selecting **Delete account** +- As an admin, you can delete a user account by navigating to the **Admin Area**, selecting the **Users** tab, selecting a user, and clicking on **Remvoe user** + +## Associated Records + +> Introduced for issues in [GitLab 9.0][ce-7393], and for merge requests, award emoji, notes, and abuse reports in [GitLab 9.1][ce-10467]. + +When a user account is deleted, not all associated records are deleted with it. Here's a list of things that will not be deleted: + +- Issues that the user created +- Merge requests that the user created +- Notes that the user created +- Abuse reports that the user reported +- Award emoji that the user craeted + + +Instead of being deleted, these records will be moved to a system-wide "Ghost User", whose sole purpose is to act as a container for such records. + + +[ce-7393]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/7393 +[ce-10467]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10467 + + diff --git a/doc/user/project/container_registry.md b/doc/user/project/container_registry.md index b6221620e58..6a2ca7fb428 100644 --- a/doc/user/project/container_registry.md +++ b/doc/user/project/container_registry.md @@ -10,6 +10,7 @@ - Starting from GitLab 8.12, if you have 2FA enabled in your account, you need to pass a personal access token instead of your password in order to login to GitLab's Container Registry. +- Multiple level image names support was added in GitLab 9.1 With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images. @@ -54,18 +55,25 @@ sure that you are using the Registry URL with the namespace and project name that is hosted on GitLab: ``` -docker build -t registry.example.com/group/project . -docker push registry.example.com/group/project +docker build -t registry.example.com/group/project/image . +docker push registry.example.com/group/project/image ``` Your image will be named after the following scheme: ``` -<registry URL>/<namespace>/<project> +<registry URL>/<namespace>/<project>/<image> ``` -As such, the name of the image is unique, but you can differentiate the images -using tags. +GitLab supports up to three levels of image repository names. + +Following examples of image tags are valid: + +``` +registry.example.com/group/project:some-tag +registry.example.com/group/project/image:latest +registry.example.com/group/project/my/image:rc1 +``` ## Use images from GitLab Container Registry @@ -73,7 +81,7 @@ To download and run a container from images hosted in GitLab Container Registry, use `docker run`: ``` -docker run [options] registry.example.com/group/project [arguments] +docker run [options] registry.example.com/group/project/image [arguments] ``` For more information on running Docker containers, visit the @@ -136,7 +144,7 @@ A user attempted to enable an S3-backed Registry. The `docker login` step went fine. However, when pushing an image, the output showed: ``` -The push refers to a repository [s3-testing.myregistry.com:4567/root/docker-test] +The push refers to a repository [s3-testing.myregistry.com:4567/root/docker-test/docker-image] dc5e59c14160: Pushing [==================================================>] 14.85 kB 03c20c1a019a: Pushing [==================================================>] 2.048 kB a08f14ef632e: Pushing [==================================================>] 2.048 kB @@ -229,7 +237,7 @@ a container image. You may need to run as root to do this. For example: ```sh docker login s3-testing.myregistry.com:4567 -docker push s3-testing.myregistry.com:4567/root/docker-test +docker push s3-testing.myregistry.com:4567/root/docker-test/docker-image ``` In the example above, we see the following trace on the mitmproxy window: diff --git a/doc/user/project/integrations/img/microsoft_teams_configuration.png b/doc/user/project/integrations/img/microsoft_teams_configuration.png Binary files differnew file mode 100644 index 00000000000..b5c9efc3dd9 --- /dev/null +++ b/doc/user/project/integrations/img/microsoft_teams_configuration.png diff --git a/doc/user/project/integrations/microsoft_teams.md b/doc/user/project/integrations/microsoft_teams.md new file mode 100644 index 00000000000..fbf9c1de443 --- /dev/null +++ b/doc/user/project/integrations/microsoft_teams.md @@ -0,0 +1,33 @@ +# Microsoft Teams Service + +## On Microsoft Teams + +To enable Microsoft Teams integration you must create an incoming webhook integration on Microsoft Teams by following the steps described in this [document](https://msdn.microsoft.com/en-us/microsoft-teams/connectors) + +## On GitLab + +After you set up Microsoft Teams, it's time to set up GitLab. + +Navigate to the [Integrations page](project_services.md#accessing-the-project-services) +and select the **Microsoft Teams Notification** service to configure it. +There, you will see a checkbox with the following events that can be triggered: + +- Push +- Issue +- Confidential issue +- Merge request +- Note +- Tag push +- Pipeline +- Wiki page + +At the end fill in your Microsoft Teams details: + +| Field | Description | +| ----- | ----------- | +| **Webhook** | The incoming webhook URL which you have to setup on Microsoft Teams. | +| **Notify only broken pipelines** | If you choose to enable the **Pipeline** event and you want to be only notified about failed pipelines. | + +After you are all done, click **Save changes** for the changes to take effect. + +![Microsoft Teams configuration](img/microsoft_teams_configuration.png)
\ No newline at end of file diff --git a/features/project/source/browse_files.feature b/features/project/source/browse_files.feature index d4b91fec6e8..5ede935c562 100644 --- a/features/project/source/browse_files.feature +++ b/features/project/source/browse_files.feature @@ -158,6 +158,8 @@ Feature: Project Source Browse Files Given I don't have write access And I click on ".gitignore" file in repo And I click button "Edit" + Then I should see a Fork/Cancel combo + And I click button "Fork" Then I should see a notice about a new fork having been created And I can edit code @@ -180,6 +182,8 @@ Feature: Project Source Browse Files Given I don't have write access And I click on ".gitignore" file in repo And I click button "Edit" + Then I should see a Fork/Cancel combo + And I click button "Fork" And I edit code And I fill the commit message And I click on "Commit Changes" diff --git a/features/steps/project/builds/summary.rb b/features/steps/project/builds/summary.rb index 19ff92f6dc6..124582de6b9 100644 --- a/features/steps/project/builds/summary.rb +++ b/features/steps/project/builds/summary.rb @@ -22,9 +22,9 @@ class Spinach::Features::ProjectBuildsSummary < Spinach::FeatureSteps end step 'recent build has been erased' do + expect(@build).not_to have_trace expect(@build.artifacts_file.exists?).to be_falsy expect(@build.artifacts_metadata.exists?).to be_falsy - expect(@build.trace).to be_empty end step 'recent build summary does not have artifacts widget' do diff --git a/features/steps/project/merge_requests.rb b/features/steps/project/merge_requests.rb index 5510c65265a..a67b3222af3 100644 --- a/features/steps/project/merge_requests.rb +++ b/features/steps/project/merge_requests.rb @@ -347,6 +347,9 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps end step 'I should see a discussion by user "John Doe" has started on diff' do + # Trigger a refresh of notes + execute_script("$(document).trigger('visibilitychange');") + wait_for_ajax page.within(".notes .discussion") do page.should have_content "#{user_exists("John Doe").name} #{user_exists("John Doe").to_reference} started a discussion" page.should have_content sample_commit.line_code_path diff --git a/features/steps/project/source/browse_files.rb b/features/steps/project/source/browse_files.rb index 5c47eaf0279..29f628763db 100644 --- a/features/steps/project/source/browse_files.rb +++ b/features/steps/project/source/browse_files.rb @@ -56,13 +56,17 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps end step 'I click button "Edit"' do - click_link 'Edit' + find('.js-edit-blob').click end step 'I cannot see the edit button' do expect(page).not_to have_link 'edit' end + step 'I click button "Fork"' do + click_link 'Fork' + end + step 'I can edit code' do set_new_content expect(evaluate_script('ace.edit("editor").getValue()')).to eq new_gitignore_content @@ -366,6 +370,12 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps end end + step 'I should see a Fork/Cancel combo' do + expect(page).to have_link 'Fork' + expect(page).to have_button 'Cancel' + expect(page).to have_content 'You don\'t have permission to edit this file. Try forking this project to edit the file.' + end + step 'I should see a notice about a new fork having been created' do expect(page).to have_content "You're not allowed to make changes to this project directly. A fork of this project has been created that you can make changes in, so you can submit a merge request." end diff --git a/features/steps/shared/builds.rb b/features/steps/shared/builds.rb index 5bc3a1f5ac4..5549fc25525 100644 --- a/features/steps/shared/builds.rb +++ b/features/steps/shared/builds.rb @@ -47,7 +47,7 @@ module SharedBuilds end step 'recent build has a build trace' do - @build.trace = 'job trace' + @build.trace.set('job trace') end step 'download of build artifacts archive starts' do diff --git a/features/steps/shared/project.rb b/features/steps/shared/project.rb index 782009a32a7..4ee879fe922 100644 --- a/features/steps/shared/project.rb +++ b/features/steps/shared/project.rb @@ -97,7 +97,7 @@ module SharedProject step 'I should see project "Shop" activity feed' do project = Project.find_by(name: "Shop") - expect(page).to have_content "#{@user.name} pushed new branch fix at #{project.name_with_namespace}" + expect(page).to have_content "pushed new branch fix at #{project.name_with_namespace}" end step 'I should see project settings' do diff --git a/lib/api/jobs.rb b/lib/api/jobs.rb index ffab0aafe59..288b03d940c 100644 --- a/lib/api/jobs.rb +++ b/lib/api/jobs.rb @@ -118,7 +118,7 @@ module API content_type 'text/plain' env['api.format'] = :binary - trace = build.trace + trace = build.trace.raw body trace end diff --git a/lib/api/runner.rb b/lib/api/runner.rb index d288369e362..6fbb02cb3aa 100644 --- a/lib/api/runner.rb +++ b/lib/api/runner.rb @@ -115,7 +115,7 @@ module API put '/:id' do job = authenticate_job! - job.update_attributes(trace: params[:trace]) if params[:trace] + job.trace.set(params[:trace]) if params[:trace] Gitlab::Metrics.add_event(:update_build, project: job.project.path_with_namespace) @@ -145,16 +145,14 @@ module API content_range = request.headers['Content-Range'] content_range = content_range.split('-') - current_length = job.trace_length - unless current_length == content_range[0].to_i - return error!('416 Range Not Satisfiable', 416, { 'Range' => "0-#{current_length}" }) + stream_size = job.trace.append(request.body.read, content_range[0].to_i) + if stream_size < 0 + return error!('416 Range Not Satisfiable', 416, { 'Range' => "0-#{-stream_size}" }) end - job.append_trace(request.body.read, content_range[0].to_i) - status 202 header 'Job-Status', job.status - header 'Range', "0-#{job.trace_length}" + header 'Range', "0-#{stream_size}" end desc 'Authorize artifacts uploading for job' do diff --git a/lib/api/services.rb b/lib/api/services.rb index 6802a99311e..65f86caaa51 100644 --- a/lib/api/services.rb +++ b/lib/api/services.rb @@ -488,6 +488,14 @@ module API desc: 'The channel name' } ], + 'microsoft-teams' => [ + { + required: true, + name: :webhook, + type: String, + desc: 'The Microsoft Teams webhook. e.g. https://outlook.office.com/webhook/…' + } + ], 'mattermost' => [ { required: true, @@ -550,6 +558,7 @@ module API RedmineService, SlackService, MattermostService, + MicrosoftTeamsService, TeamcityService, ] diff --git a/lib/api/v3/builds.rb b/lib/api/v3/builds.rb index 6f97102c6ef..4dd03cdf24b 100644 --- a/lib/api/v3/builds.rb +++ b/lib/api/v3/builds.rb @@ -120,7 +120,7 @@ module API content_type 'text/plain' env['api.format'] = :binary - trace = build.trace + trace = build.trace.raw body trace end diff --git a/lib/api/v3/services.rb b/lib/api/v3/services.rb index 3bacaeee032..bbe07ed4212 100644 --- a/lib/api/v3/services.rb +++ b/lib/api/v3/services.rb @@ -501,6 +501,12 @@ module API desc: 'The channel name' } ], + 'microsoft-teams' => [ + required: true, + name: :webhook, + type: String, + desc: 'The Microsoft Teams webhook. e.g. https://outlook.office.com/webhook/…' + ], 'mattermost' => [ { required: true, diff --git a/lib/ci/ansi2html.rb b/lib/ci/ansi2html.rb index b3ccad7b28d..1020452480a 100644 --- a/lib/ci/ansi2html.rb +++ b/lib/ci/ansi2html.rb @@ -132,34 +132,54 @@ module Ci STATE_PARAMS = [:offset, :n_open_tags, :fg_color, :bg_color, :style_mask].freeze - def convert(raw, new_state) + def convert(stream, new_state) reset_state - restore_state(raw, new_state) if new_state.present? - - start = @offset - ansi = raw[@offset..-1] + restore_state(new_state, stream) if new_state.present? + + append = false + truncated = false + + cur_offset = stream.tell + if cur_offset > @offset + @offset = cur_offset + truncated = true + else + stream.seek(@offset) + append = @offset > 0 + end + start_offset = @offset open_new_tag - s = StringScanner.new(ansi) - until s.eos? - if s.scan(/\e([@-_])(.*?)([@-~])/) - handle_sequence(s) - elsif s.scan(/\e(([@-_])(.*?)?)?$/) - break - elsif s.scan(/</) - @out << '<' - elsif s.scan(/\r?\n/) - @out << '<br>' - else - @out << s.scan(/./m) + stream.each_line do |line| + s = StringScanner.new(line) + until s.eos? + if s.scan(/\e([@-_])(.*?)([@-~])/) + handle_sequence(s) + elsif s.scan(/\e(([@-_])(.*?)?)?$/) + break + elsif s.scan(/</) + @out << '<' + elsif s.scan(/\r?\n/) + @out << '<br>' + else + @out << s.scan(/./m) + end + @offset += s.matched_size end - @offset += s.matched_size end close_open_tags() - { state: state, html: @out, text: ansi[0, @offset - start], append: start > 0 } + OpenStruct.new( + html: @out, + state: state, + append: append, + truncated: truncated, + offset: start_offset, + size: stream.tell - start_offset, + total: stream.size + ) end def handle_sequence(s) @@ -240,10 +260,10 @@ module Ci Base64.urlsafe_encode64(state.to_json) end - def restore_state(raw, new_state) + def restore_state(new_state, stream) state = Base64.urlsafe_decode64(new_state) state = JSON.parse(state, symbolize_names: true) - return if state[:offset].to_i > raw.length + return if state[:offset].to_i > stream.size STATE_PARAMS.each do |param| send("#{param}=".to_sym, state[param]) diff --git a/lib/ci/api/builds.rb b/lib/ci/api/builds.rb index 95cc6308c3b..67b269b330c 100644 --- a/lib/ci/api/builds.rb +++ b/lib/ci/api/builds.rb @@ -61,7 +61,7 @@ module Ci update_runner_info - build.update_attributes(trace: params[:trace]) if params[:trace] + build.trace.set(params[:trace]) if params[:trace] Gitlab::Metrics.add_event(:update_build, project: build.project.path_with_namespace) @@ -92,16 +92,14 @@ module Ci content_range = request.headers['Content-Range'] content_range = content_range.split('-') - current_length = build.trace_length - unless current_length == content_range[0].to_i - return error!('416 Range Not Satisfiable', 416, { 'Range' => "0-#{current_length}" }) + stream_size = build.trace.append(request.body.read, content_range[0].to_i) + if stream_size < 0 + return error!('416 Range Not Satisfiable', 416, { 'Range' => "0-#{-stream_size}" }) end - build.append_trace(request.body.read, content_range[0].to_i) - status 202 header 'Build-Status', build.status - header 'Range', "0-#{build.trace_length}" + header 'Range', "0-#{stream_size}" end # Authorize artifacts uploading for build - Runners only diff --git a/lib/container_registry/blob.rb b/lib/container_registry/blob.rb index eb5a2596177..d5f85f9fcad 100644 --- a/lib/container_registry/blob.rb +++ b/lib/container_registry/blob.rb @@ -38,11 +38,11 @@ module ContainerRegistry end def delete - client.delete_blob(repository.name, digest) + client.delete_blob(repository.path, digest) end def data - @data ||= client.blob(repository.name, digest, type) + @data ||= client.blob(repository.path, digest, type) end end end diff --git a/lib/container_registry/path.rb b/lib/container_registry/path.rb new file mode 100644 index 00000000000..a4b5f2aba6c --- /dev/null +++ b/lib/container_registry/path.rb @@ -0,0 +1,70 @@ +module ContainerRegistry + ## + # Class responsible for extracting project and repository name from + # image repository path provided by a containers registry API response. + # + # Example: + # + # some/group/my_project/my/image -> + # project: some/group/my_project + # repository: my/image + # + class Path + InvalidRegistryPathError = Class.new(StandardError) + + LEVELS_SUPPORTED = 3 + + def initialize(path) + @path = path + end + + def valid? + @path =~ Gitlab::Regex.container_repository_name_regex && + components.size > 1 && + components.size < Namespace::NUMBER_OF_ANCESTORS_ALLOWED + end + + def components + @components ||= @path.to_s.split('/') + end + + def nodes + raise InvalidRegistryPathError unless valid? + + @nodes ||= components.size.downto(2).map do |length| + components.take(length).join('/') + end + end + + def has_project? + repository_project.present? + end + + def has_repository? + return false unless has_project? + + repository_project.container_repositories + .where(name: repository_name).any? + end + + def root_repository? + @path == repository_project.full_path + end + + def repository_project + @project ||= Project + .where_full_path_in(nodes.first(LEVELS_SUPPORTED)) + .first + end + + def repository_name + return unless has_project? + + @path.remove(%r(^#{Regexp.escape(repository_project.full_path)}/?)) + end + + def to_s + @path + end + end +end diff --git a/lib/container_registry/registry.rb b/lib/container_registry/registry.rb index 0e634f6b6ef..63bce655f57 100644 --- a/lib/container_registry/registry.rb +++ b/lib/container_registry/registry.rb @@ -8,10 +8,6 @@ module ContainerRegistry @client = ContainerRegistry::Client.new(uri, options) end - def repository(name) - ContainerRegistry::Repository.new(self, name) - end - private def default_path diff --git a/lib/container_registry/repository.rb b/lib/container_registry/repository.rb deleted file mode 100644 index 0e4a7cb3cc9..00000000000 --- a/lib/container_registry/repository.rb +++ /dev/null @@ -1,48 +0,0 @@ -module ContainerRegistry - class Repository - attr_reader :registry, :name - - delegate :client, to: :registry - - def initialize(registry, name) - @registry, @name = registry, name - end - - def path - [registry.path, name].compact.join('/') - end - - def tag(tag) - ContainerRegistry::Tag.new(self, tag) - end - - def manifest - return @manifest if defined?(@manifest) - - @manifest = client.repository_tags(name) - end - - def valid? - manifest.present? - end - - def tags - return @tags if defined?(@tags) - return [] unless manifest && manifest['tags'] - - @tags = manifest['tags'].map do |tag| - ContainerRegistry::Tag.new(self, tag) - end - end - - def blob(config) - ContainerRegistry::Blob.new(self, config) - end - - def delete_tags - return unless tags - - tags.all?(&:delete) - end - end -end diff --git a/lib/container_registry/tag.rb b/lib/container_registry/tag.rb index 59040199920..d00e6191e7e 100644 --- a/lib/container_registry/tag.rb +++ b/lib/container_registry/tag.rb @@ -22,9 +22,7 @@ module ContainerRegistry end def manifest - return @manifest if defined?(@manifest) - - @manifest = client.repository_manifest(repository.name, name) + @manifest ||= client.repository_manifest(repository.path, name) end def path @@ -38,9 +36,7 @@ module ContainerRegistry end def digest - return @digest if defined?(@digest) - - @digest = client.repository_tag_digest(repository.name, name) + @digest ||= client.repository_tag_digest(repository.path, name) end def config_blob @@ -82,7 +78,7 @@ module ContainerRegistry def delete return unless digest - client.delete_repository_tag(repository.name, digest) + client.delete_repository_tag(repository.path, digest) end end end diff --git a/lib/gitlab/ci/cron_parser.rb b/lib/gitlab/ci/cron_parser.rb new file mode 100644 index 00000000000..a3cc350ef22 --- /dev/null +++ b/lib/gitlab/ci/cron_parser.rb @@ -0,0 +1,34 @@ +module Gitlab + module Ci + class CronParser + VALID_SYNTAX_SAMPLE_TIME_ZONE = 'UTC'.freeze + VALID_SYNTAX_SAMPLE_CRON = '* * * * *'.freeze + + def initialize(cron, cron_timezone = 'UTC') + @cron = cron + @cron_timezone = cron_timezone + end + + def next_time_from(time) + @cron_line ||= try_parse_cron(@cron, @cron_timezone) + @cron_line.next_time(time).in_time_zone(Time.zone) if @cron_line.present? + end + + def cron_valid? + try_parse_cron(@cron, VALID_SYNTAX_SAMPLE_TIME_ZONE).present? + end + + def cron_timezone_valid? + try_parse_cron(VALID_SYNTAX_SAMPLE_CRON, @cron_timezone).present? + end + + private + + def try_parse_cron(cron, cron_timezone) + Rufus::Scheduler.parse("#{cron} #{cron_timezone}") + rescue + # noop + end + end + end +end diff --git a/lib/gitlab/ci/trace.rb b/lib/gitlab/ci/trace.rb new file mode 100644 index 00000000000..5b835bb669a --- /dev/null +++ b/lib/gitlab/ci/trace.rb @@ -0,0 +1,136 @@ +module Gitlab + module Ci + class Trace + attr_reader :job + + delegate :old_trace, to: :job + + def initialize(job) + @job = job + end + + def html(last_lines: nil) + read do |stream| + stream.html(last_lines: last_lines) + end + end + + def raw(last_lines: nil) + read do |stream| + stream.raw(last_lines: last_lines) + end + end + + def extract_coverage(regex) + read do |stream| + stream.extract_coverage(regex) + end + end + + def set(data) + write do |stream| + data = job.hide_secrets(data) + stream.set(data) + end + end + + def append(data, offset) + write do |stream| + current_length = stream.size + return -current_length unless current_length == offset + + data = job.hide_secrets(data) + stream.append(data, offset) + stream.size + end + end + + def exist? + current_path.present? || old_trace.present? + end + + def read + stream = Gitlab::Ci::Trace::Stream.new do + if current_path + File.open(current_path, "rb") + elsif old_trace + StringIO.new(old_trace) + end + end + + yield stream + ensure + stream&.close + end + + def write + stream = Gitlab::Ci::Trace::Stream.new do + File.open(ensure_path, "a+b") + end + + yield(stream).tap do + job.touch if job.needs_touch? + end + ensure + stream&.close + end + + def erase! + paths.each do |trace_path| + FileUtils.rm(trace_path, force: true) + end + + job.erase_old_trace! + end + + private + + def ensure_path + return current_path if current_path + + ensure_directory + default_path + end + + def ensure_directory + unless Dir.exist?(default_directory) + FileUtils.mkdir_p(default_directory) + end + end + + def current_path + @current_path ||= paths.find do |trace_path| + File.exist?(trace_path) + end + end + + def paths + [ + default_path, + deprecated_path + ].compact + end + + def default_directory + File.join( + Settings.gitlab_ci.builds_path, + job.created_at.utc.strftime("%Y_%m"), + job.project_id.to_s + ) + end + + def default_path + File.join(default_directory, "#{job.id}.log") + end + + def deprecated_path + File.join( + Settings.gitlab_ci.builds_path, + job.created_at.utc.strftime("%Y_%m"), + job.project.ci_id.to_s, + "#{job.id}.log" + ) if job.project&.ci_id + end + end + end +end diff --git a/lib/gitlab/ci/trace/stream.rb b/lib/gitlab/ci/trace/stream.rb new file mode 100644 index 00000000000..2af94e2c60e --- /dev/null +++ b/lib/gitlab/ci/trace/stream.rb @@ -0,0 +1,119 @@ +module Gitlab + module Ci + class Trace + # This was inspired from: http://stackoverflow.com/a/10219411/1520132 + class Stream + BUFFER_SIZE = 4096 + LIMIT_SIZE = 50.kilobytes + + attr_reader :stream + + delegate :close, :tell, :seek, :size, :path, :truncate, to: :stream, allow_nil: true + + delegate :valid?, to: :stream, as: :present?, allow_nil: true + + def initialize + @stream = yield + end + + def valid? + self.stream.present? + end + + def file? + self.path.present? + end + + def limit(last_bytes = LIMIT_SIZE) + stream_size = size + if stream_size < last_bytes + last_bytes = stream_size + end + stream.seek(-last_bytes, IO::SEEK_END) + end + + def append(data, offset) + stream.truncate(offset) + stream.seek(0, IO::SEEK_END) + stream.write(data) + stream.flush() + end + + def set(data) + truncate(0) + stream.write(data) + stream.flush() + end + + def raw(last_lines: nil) + return unless valid? + + if last_lines.to_i > 0 + read_last_lines(last_lines) + else + stream.read + end + end + + def html_with_state(state = nil) + ::Ci::Ansi2html.convert(stream, state) + end + + def html(last_lines: nil) + text = raw(last_lines: last_lines) + stream = StringIO.new(text) + ::Ci::Ansi2html.convert(stream).html + end + + def extract_coverage(regex) + return unless valid? + return unless regex + + regex = Regexp.new(regex) + + match = "" + + stream.each_line do |line| + matches = line.scan(regex) + next unless matches.is_a?(Array) + + match = matches.flatten.last + coverage = match.gsub(/\d+(\.\d+)?/).first + return coverage.to_f if coverage.present? + end + rescue + # if bad regex or something goes wrong we dont want to interrupt transition + # so we just silentrly ignore error for now + end + + private + + def read_last_lines(last_lines) + chunks = [] + pos = lines = 0 + max = stream.size + + # We want an extra line to make sure fist line has full contents + while lines <= last_lines && pos < max + pos += BUFFER_SIZE + + buf = + if pos <= max + stream.seek(-pos, IO::SEEK_END) + stream.read(BUFFER_SIZE) + else # Reached the head, read only left + stream.seek(0) + stream.read(BUFFER_SIZE - (pos - max)) + end + + lines += buf.count("\n") + chunks.unshift(buf) + end + + chunks.join.lines.last(last_lines).join + .force_encoding(Encoding.default_external) + end + end + end + end +end diff --git a/lib/gitlab/ci/trace_reader.rb b/lib/gitlab/ci/trace_reader.rb deleted file mode 100644 index 1d7ddeb3e0f..00000000000 --- a/lib/gitlab/ci/trace_reader.rb +++ /dev/null @@ -1,50 +0,0 @@ -module Gitlab - module Ci - # This was inspired from: http://stackoverflow.com/a/10219411/1520132 - class TraceReader - BUFFER_SIZE = 4096 - - attr_accessor :path, :buffer_size - - def initialize(new_path, buffer_size: BUFFER_SIZE) - self.path = new_path - self.buffer_size = Integer(buffer_size) - end - - def read(last_lines: nil) - if last_lines - read_last_lines(last_lines) - else - File.read(path) - end - end - - def read_last_lines(max_lines) - File.open(path) do |file| - chunks = [] - pos = lines = 0 - max = file.size - - # We want an extra line to make sure fist line has full contents - while lines <= max_lines && pos < max - pos += buffer_size - - buf = if pos <= max - file.seek(-pos, IO::SEEK_END) - file.read(buffer_size) - else # Reached the head, read only left - file.seek(0) - file.read(buffer_size - (pos - max)) - end - - lines += buf.count("\n") - chunks.unshift(buf) - end - - chunks.join.lines.last(max_lines).join - .force_encoding(Encoding.default_external) - end - end - end - end -end diff --git a/lib/gitlab/diff/line.rb b/lib/gitlab/diff/line.rb index 114656958e3..0a15c6d9358 100644 --- a/lib/gitlab/diff/line.rb +++ b/lib/gitlab/diff/line.rb @@ -33,6 +33,10 @@ module Gitlab new_pos unless removed? || meta? end + def line + new_line || old_line + end + def unchanged? type.nil? end diff --git a/lib/gitlab/email/handler/create_note_handler.rb b/lib/gitlab/email/handler/create_note_handler.rb index d87ba427f4b..0e22f2189ee 100644 --- a/lib/gitlab/email/handler/create_note_handler.rb +++ b/lib/gitlab/email/handler/create_note_handler.rb @@ -1,4 +1,3 @@ - require 'gitlab/email/handler/base_handler' require 'gitlab/email/handler/reply_processing' @@ -42,17 +41,7 @@ module Gitlab end def create_note - Notes::CreateService.new( - project, - author, - note: message, - noteable_type: sent_notification.noteable_type, - noteable_id: sent_notification.noteable_id, - commit_id: sent_notification.commit_id, - line_code: sent_notification.line_code, - position: sent_notification.position, - type: sent_notification.note_type - ).execute + sent_notification.create_reply(message) end end end diff --git a/lib/gitlab/etag_caching/middleware.rb b/lib/gitlab/etag_caching/middleware.rb index cd4e318033d..630fe4fa849 100644 --- a/lib/gitlab/etag_caching/middleware.rb +++ b/lib/gitlab/etag_caching/middleware.rb @@ -2,26 +2,34 @@ module Gitlab module EtagCaching class Middleware RESERVED_WORDS = NamespaceValidator::WILDCARD_ROUTES.map { |word| "/#{word}/" }.join('|') - ROUTE_REGEXP = Regexp.union( - %r(^(?!.*(#{RESERVED_WORDS})).*/noteable/issue/\d+/notes\z), - %r(^(?!.*(#{RESERVED_WORDS})).*/issues/\d+/rendered_title\z) - ) + ROUTES = [ + { + regexp: %r(^(?!.*(#{RESERVED_WORDS})).*/noteable/issue/\d+/notes\z), + name: 'issue_notes' + }, + { + regexp: %r(^(?!.*(#{RESERVED_WORDS})).*/issues/\d+/rendered_title\z), + name: 'issue_title' + } + ].freeze def initialize(app) @app = app end def call(env) - return @app.call(env) unless enabled_for_current_route?(env) - Gitlab::Metrics.add_event(:etag_caching_middleware_used) + route = match_current_route(env) + return @app.call(env) unless route + + track_event(:etag_caching_middleware_used, route) etag, cached_value_present = get_etag(env) if_none_match = env['HTTP_IF_NONE_MATCH'] if if_none_match == etag - handle_cache_hit(etag) + handle_cache_hit(etag, route) else - track_cache_miss(if_none_match, cached_value_present) + track_cache_miss(if_none_match, cached_value_present, route) status, headers, body = @app.call(env) headers['ETag'] = etag @@ -31,8 +39,8 @@ module Gitlab private - def enabled_for_current_route?(env) - ROUTE_REGEXP.match(env['PATH_INFO']) + def match_current_route(env) + ROUTES.find { |route| route[:regexp].match(env['PATH_INFO']) } end def get_etag(env) @@ -52,23 +60,27 @@ module Gitlab %Q{W/"#{value}"} end - def handle_cache_hit(etag) - Gitlab::Metrics.add_event(:etag_caching_cache_hit) + def handle_cache_hit(etag, route) + track_event(:etag_caching_cache_hit, route) status_code = Gitlab::PollingInterval.polling_enabled? ? 304 : 429 [status_code, { 'ETag' => etag }, ['']] end - def track_cache_miss(if_none_match, cached_value_present) + def track_cache_miss(if_none_match, cached_value_present, route) if if_none_match.blank? - Gitlab::Metrics.add_event(:etag_caching_header_missing) + track_event(:etag_caching_header_missing, route) elsif !cached_value_present - Gitlab::Metrics.add_event(:etag_caching_key_not_found) + track_event(:etag_caching_key_not_found, route) else - Gitlab::Metrics.add_event(:etag_caching_resource_changed) + track_event(:etag_caching_resource_changed, route) end end + + def track_event(name, route) + Gitlab::Metrics.add_event(name, endpoint: route[:name]) + end end end end diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index 2e4314932c8..9e338282e96 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -452,6 +452,21 @@ module Gitlab Gitlab::Git::DiffCollection.new(diff_patches(from, to, options, *paths), options) end + # Returns a RefName for a given SHA + def ref_name_for_sha(ref_path, sha) + Gitlab::GitalyClient.migrate(:find_ref_name) do |is_enabled| + if is_enabled + gitaly_ref_client.find_ref_name(sha, ref_path) + else + args = %W(#{Gitlab.config.git.bin_path} for-each-ref --count=1 #{ref_path} --contains #{sha}) + + # Not found -> ["", 0] + # Found -> ["b8d95eb4969eefacb0a58f6a28f6803f8070e7b9 commit\trefs/environments/production/77\n", 0] + Gitlab::Popen.popen(args, @path).first.split.last + end + end + end + # Returns commits collection # # Ex. diff --git a/lib/gitlab/gitaly_client/ref.rb b/lib/gitlab/gitaly_client/ref.rb index bfc5fa573c7..fcdf452d567 100644 --- a/lib/gitlab/gitaly_client/ref.rb +++ b/lib/gitlab/gitaly_client/ref.rb @@ -23,6 +23,16 @@ module Gitlab consume_refs_response(stub.find_all_tag_names(request), prefix: 'refs/tags/') end + def find_ref_name(commit_id, ref_prefix) + request = Gitaly::FindRefNameRequest.new( + repository: @repository, + commit_id: commit_id, + prefix: ref_prefix + ) + + stub.find_ref_name(request).name + end + private def consume_refs_response(response, prefix:) diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml index f69288f7d67..f5e1e385ff9 100644 --- a/lib/gitlab/import_export/import_export.yml +++ b/lib/gitlab/import_export/import_export.yml @@ -39,7 +39,8 @@ project_tree: - :author - :events - :statuses - - :triggers + - triggers: + - :trigger_schedule - :deploy_keys - :services - :hooks diff --git a/lib/gitlab/import_export/relation_factory.rb b/lib/gitlab/import_export/relation_factory.rb index fb43e7ccdbb..2ba12f5f924 100644 --- a/lib/gitlab/import_export/relation_factory.rb +++ b/lib/gitlab/import_export/relation_factory.rb @@ -5,6 +5,7 @@ module Gitlab pipelines: 'Ci::Pipeline', statuses: 'commit_status', triggers: 'Ci::Trigger', + trigger_schedule: 'Ci::TriggerSchedule', builds: 'Ci::Build', hooks: 'ProjectHook', merge_access_levels: 'ProtectedBranch::MergeAccessLevel', diff --git a/lib/gitlab/regex.rb b/lib/gitlab/regex.rb index 5e5f5ff1589..e599dd4a656 100644 --- a/lib/gitlab/regex.rb +++ b/lib/gitlab/regex.rb @@ -121,6 +121,13 @@ module Gitlab git_reference_regex end + ## + # Docker Distribution Registry 2.4.1 repository name rules + # + def container_repository_name_regex + @container_repository_regex ||= %r{\A[a-z0-9]+(?:[-._/][a-z0-9]+)*\Z} + end + def environment_name_regex @environment_name_regex ||= /\A[a-zA-Z0-9_\\\/\${}. -]+\z/.freeze end diff --git a/lib/microsoft_teams/activity.rb b/lib/microsoft_teams/activity.rb new file mode 100644 index 00000000000..d2c420efdaf --- /dev/null +++ b/lib/microsoft_teams/activity.rb @@ -0,0 +1,19 @@ +module MicrosoftTeams + class Activity + def initialize(title:, subtitle:, text:, image:) + @title = title + @subtitle = subtitle + @text = text + @image = image + end + + def prepare + { + 'activityTitle' => @title, + 'activitySubtitle' => @subtitle, + 'activityText' => @text, + 'activityImage' => @image + } + end + end +end diff --git a/lib/microsoft_teams/notifier.rb b/lib/microsoft_teams/notifier.rb new file mode 100644 index 00000000000..3bef68a1bcb --- /dev/null +++ b/lib/microsoft_teams/notifier.rb @@ -0,0 +1,46 @@ +module MicrosoftTeams + class Notifier + def initialize(webhook) + @webhook = webhook + @header = { 'Content-type' => 'application/json' } + end + + def ping(options = {}) + result = false + + begin + response = HTTParty.post( + @webhook.to_str, + headers: @header, + body: body(options) + ) + + result = true if response + rescue HTTParty::Error, StandardError => error + Rails.logger.info("#{self.class.name}: Error while connecting to #{@webhook}: #{error.message}") + end + + result + end + + private + + def body(options = {}) + result = { 'sections' => [] } + + result['title'] = options[:title] + result['summary'] = options[:pretext] + result['sections'] << MicrosoftTeams::Activity.new(options[:activity]).prepare + + attachments = options[:attachments] + unless attachments.blank? + result['sections'] << { + 'title' => 'Details', + 'facts' => [{ 'name' => 'Attachments', 'value' => attachments }] + } + end + + result.to_json + end + end +end diff --git a/lib/support/init.d/gitlab b/lib/support/init.d/gitlab index 09e121e5120..6e351365de0 100755 --- a/lib/support/init.d/gitlab +++ b/lib/support/init.d/gitlab @@ -326,8 +326,7 @@ start_gitlab() { echo "Gitaly is already running with pid $gapid, not restarting" else $app_root/bin/daemon_with_pidfile $gitaly_pid_path \ - $app_root/bin/with_env $gitaly_dir/env \ - $gitaly_dir/gitaly >> $gitaly_log 2>&1 & + $gitaly_dir/gitaly $gitaly_dir/config.toml >> $gitaly_log 2>&1 & fi fi diff --git a/lib/tasks/import.rake b/lib/tasks/import.rake new file mode 100644 index 00000000000..350afeb5c0b --- /dev/null +++ b/lib/tasks/import.rake @@ -0,0 +1,204 @@ +require 'benchmark' +require 'rainbow/ext/string' +require_relative '../gitlab/shell_adapter' +require_relative '../gitlab/github_import/importer' + +class NewImporter < ::Gitlab::GithubImport::Importer + def execute + # Same as ::Gitlab::GithubImport::Importer#execute, but showing some progress. + puts 'Importing repository...'.color(:aqua) + import_repository unless project.repository_exists? + + puts 'Importing labels...'.color(:aqua) + import_labels + + puts 'Importing milestones...'.color(:aqua) + import_milestones + + puts 'Importing pull requests...'.color(:aqua) + import_pull_requests + + puts 'Importing issues...'.color(:aqua) + import_issues + + puts 'Importing issue comments...'.color(:aqua) + import_comments(:issues) + + puts 'Importing pull request comments...'.color(:aqua) + import_comments(:pull_requests) + + puts 'Importing wiki...'.color(:aqua) + import_wiki + + # Gitea doesn't have a Release API yet + # See https://github.com/go-gitea/gitea/issues/330 + unless project.gitea_import? + import_releases + end + + handle_errors + + project.repository.after_import + project.import_finish + + true + end + + def import_repository + begin + raise 'Blocked import URL.' if Gitlab::UrlBlocker.blocked_url?(project.import_url) + + gitlab_shell.import_repository(project.repository_storage_path, project.path_with_namespace, project.import_url) + rescue => e + project.repository.before_import if project.repository_exists? + + raise "Error importing repository #{project.import_url} into #{project.path_with_namespace} - #{e.message}" + end + end +end + +class GithubImport + def self.run!(*args) + new(*args).run! + end + + def initialize(token, gitlab_username, project_path, extras) + @token = token + @project_path = project_path + @current_user = User.find_by_username(gitlab_username) + @github_repo = extras.empty? ? nil : extras.first + end + + def run! + @repo = GithubRepos.new(@token, @current_user, @github_repo).choose_one! + + raise 'No repo found!' unless @repo + + show_warning! + + @project = Project.find_by_full_path(@project_path) || new_project + + import! + end + + private + + def show_warning! + puts "This will import GH #{@repo.full_name.bright} into GL #{@project_path.bright} as #{@current_user.name}" + puts "Permission checks are ignored. Press any key to continue.".color(:red) + + STDIN.getch + + puts 'Starting the import...'.color(:green) + end + + def import! + import_url = @project.import_url.gsub(/\:\/\/(.*@)?/, "://#{@token}@") + @project.update(import_url: import_url) + + @project.import_start + + timings = Benchmark.measure do + NewImporter.new(@project).execute + end + + puts "Import finished. Timings: #{timings}".color(:green) + end + + def new_project + Project.transaction do + namespace_path, _sep, name = @project_path.rpartition('/') + namespace = find_or_create_namespace(namespace_path) + + Project.create!( + import_url: "https://#{@token}@github.com/#{@repo.full_name}.git", + name: name, + path: name, + description: @repo.description, + namespace: namespace, + visibility_level: visibility_level, + import_type: 'github', + import_source: @repo.full_name, + creator: @current_user + ) + end + end + + def find_or_create_namespace(names) + return @current_user.namespace if names == @current_user.namespace_path + return @current_user.namespace unless @current_user.can_create_group? + + names = params[:target_namespace].presence || names + full_path_namespace = Namespace.find_by_full_path(names) + + return full_path_namespace if full_path_namespace + + names.split('/').inject(nil) do |parent, name| + begin + namespace = Group.create!(name: name, + path: name, + owner: @current_user, + parent: parent) + namespace.add_owner(@current_user) + + namespace + rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid + Namespace.where(parent: parent).find_by_path_or_name(name) + end + end + end + + def full_path_namespace(names) + @full_path_namespace ||= Namespace.find_by_full_path(names) + end + + def visibility_level + @repo.private ? Gitlab::VisibilityLevel::PRIVATE : current_application_settings.default_project_visibility + end +end + +class GithubRepos + def initialize(token, current_user, github_repo) + @token = token + @current_user = current_user + @github_repo = github_repo + end + + def choose_one! + return found_github_repo if @github_repo + + repos.each do |repo| + print "ID: #{repo[:id].to_s.bright} ".color(:green) + puts "- Name: #{repo[:full_name]}".color(:green) + end + + print 'ID? '.bright + + repos.find { |repo| repo[:id] == repo_id } + end + + def found_github_repo + repos.find { |repo| repo[:full_name] == @github_repo } + end + + def repo_id + @repo_id ||= STDIN.gets.chomp.to_i + end + + def repos + @repos ||= client.repos + end + + def client + @client ||= Gitlab::GithubImport::Client.new(@token, {}) + end +end + +namespace :import do + desc 'Import a GitHub project - Example: import:github[ToKeN,root,root/blah,my/github_repo] (optional my/github_repo)' + task :github, [:token, :gitlab_username, :project_path] => :environment do |_t, args| + abort 'Project path must be: namespace(s)/project_name'.color(:red) unless args.project_path.include?('/') + + GithubImport.run!(args.token, args.gitlab_username, args.project_path, args.extras) + end +end diff --git a/package.json b/package.json index 312e38f7407..5be299da699 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "webpack-prod": "NODE_ENV=production webpack --config config/webpack.config.js" }, "dependencies": { + "@gitlab-org/droplab": "^0.1.0", "babel-core": "^6.22.1", "babel-loader": "^6.2.10", "babel-plugin-transform-define": "^1.2.0", diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index 81cbccd5436..760f33b09c1 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -100,8 +100,6 @@ describe ApplicationController do end describe '#route_not_found' do - let(:controller) { ApplicationController.new } - it 'renders 404 if authenticated' do allow(controller).to receive(:current_user).and_return(user) expect(controller).to receive(:not_found) @@ -115,4 +113,203 @@ describe ApplicationController do controller.send(:route_not_found) end end + + context 'two-factor authentication' do + let(:controller) { ApplicationController.new } + + describe '#check_two_factor_requirement' do + subject { controller.send :check_two_factor_requirement } + + it 'does not redirect if 2FA is not required' do + allow(controller).to receive(:two_factor_authentication_required?).and_return(false) + expect(controller).not_to receive(:redirect_to) + + subject + end + + it 'does not redirect if user is not logged in' do + allow(controller).to receive(:two_factor_authentication_required?).and_return(true) + allow(controller).to receive(:current_user).and_return(nil) + expect(controller).not_to receive(:redirect_to) + + subject + end + + it 'does not redirect if user has 2FA enabled' do + allow(controller).to receive(:two_factor_authentication_required?).and_return(true) + allow(controller).to receive(:current_user).twice.and_return(user) + allow(user).to receive(:two_factor_enabled?).and_return(true) + expect(controller).not_to receive(:redirect_to) + + subject + end + + it 'does not redirect if 2FA setup can be skipped' do + allow(controller).to receive(:two_factor_authentication_required?).and_return(true) + allow(controller).to receive(:current_user).twice.and_return(user) + allow(user).to receive(:two_factor_enabled?).and_return(false) + allow(controller).to receive(:skip_two_factor?).and_return(true) + expect(controller).not_to receive(:redirect_to) + + subject + end + + it 'redirects to 2FA setup otherwise' do + allow(controller).to receive(:two_factor_authentication_required?).and_return(true) + allow(controller).to receive(:current_user).twice.and_return(user) + allow(user).to receive(:two_factor_enabled?).and_return(false) + allow(controller).to receive(:skip_two_factor?).and_return(false) + allow(controller).to receive(:profile_two_factor_auth_path) + expect(controller).to receive(:redirect_to) + + subject + end + end + + describe '#two_factor_authentication_required?' do + subject { controller.send :two_factor_authentication_required? } + + it 'returns false if no 2FA requirement is present' do + allow(controller).to receive(:current_user).and_return(nil) + + expect(subject).to be_falsey + end + + it 'returns true if a 2FA requirement is set in the application settings' do + stub_application_setting require_two_factor_authentication: true + allow(controller).to receive(:current_user).and_return(nil) + + expect(subject).to be_truthy + end + + it 'returns true if a 2FA requirement is set on the user' do + user.require_two_factor_authentication_from_group = true + allow(controller).to receive(:current_user).and_return(user) + + expect(subject).to be_truthy + end + end + + describe '#two_factor_grace_period' do + subject { controller.send :two_factor_grace_period } + + it 'returns the grace period from the application settings' do + stub_application_setting two_factor_grace_period: 23 + allow(controller).to receive(:current_user).and_return(nil) + + expect(subject).to eq 23 + end + + context 'with a 2FA requirement set on the user' do + let(:user) { create :user, require_two_factor_authentication_from_group: true, two_factor_grace_period: 23 } + + it 'returns the user grace period if lower than the application grace period' do + stub_application_setting two_factor_grace_period: 24 + allow(controller).to receive(:current_user).and_return(user) + + expect(subject).to eq 23 + end + + it 'returns the application grace period if lower than the user grace period' do + stub_application_setting two_factor_grace_period: 22 + allow(controller).to receive(:current_user).and_return(user) + + expect(subject).to eq 22 + end + end + end + + describe '#two_factor_grace_period_expired?' do + subject { controller.send :two_factor_grace_period_expired? } + + before do + allow(controller).to receive(:current_user).and_return(user) + end + + it 'returns false if the user has not started their grace period yet' do + expect(subject).to be_falsey + end + + context 'with grace period started' do + let(:user) { create :user, otp_grace_period_started_at: 2.hours.ago } + + it 'returns true if the grace period has expired' do + allow(controller).to receive(:two_factor_grace_period).and_return(1) + + expect(subject).to be_truthy + end + + it 'returns false if the grace period is still active' do + allow(controller).to receive(:two_factor_grace_period).and_return(3) + + expect(subject).to be_falsey + end + end + end + + describe '#two_factor_skippable' do + subject { controller.send :two_factor_skippable? } + + before do + allow(controller).to receive(:current_user).and_return(user) + end + + it 'returns false if 2FA is not required' do + allow(controller).to receive(:two_factor_authentication_required?).and_return(false) + + expect(subject).to be_falsey + end + + it 'returns false if the user has already enabled 2FA' do + allow(controller).to receive(:two_factor_authentication_required?).and_return(true) + allow(user).to receive(:two_factor_enabled?).and_return(true) + + expect(subject).to be_falsey + end + + it 'returns false if the 2FA grace period has expired' do + allow(controller).to receive(:two_factor_authentication_required?).and_return(true) + allow(user).to receive(:two_factor_enabled?).and_return(false) + allow(controller).to receive(:two_factor_grace_period_expired?).and_return(true) + + expect(subject).to be_falsey + end + + it 'returns true otherwise' do + allow(controller).to receive(:two_factor_authentication_required?).and_return(true) + allow(user).to receive(:two_factor_enabled?).and_return(false) + allow(controller).to receive(:two_factor_grace_period_expired?).and_return(false) + + expect(subject).to be_truthy + end + end + + describe '#skip_two_factor?' do + subject { controller.send :skip_two_factor? } + + it 'returns false if 2FA setup was not skipped' do + allow(controller).to receive(:session).and_return({}) + + expect(subject).to be_falsey + end + + context 'with 2FA setup skipped' do + before do + allow(controller).to receive(:session).and_return({ skip_two_factor: 2.hours.from_now }) + end + + it 'returns false if the grace period has expired' do + Timecop.freeze(3.hours.from_now) do + expect(subject).to be_falsey + end + end + + it 'returns true if the grace period is still active' do + Timecop.freeze(1.hour.from_now) do + expect(subject).to be_truthy + end + end + end + end + end end diff --git a/spec/controllers/projects/blob_controller_spec.rb b/spec/controllers/projects/blob_controller_spec.rb index ec36a64b415..3e9f272a0d8 100644 --- a/spec/controllers/projects/blob_controller_spec.rb +++ b/spec/controllers/projects/blob_controller_spec.rb @@ -2,15 +2,10 @@ require 'rails_helper' describe Projects::BlobController do let(:project) { create(:project, :public, :repository) } - let(:user) { create(:user) } - - before do - project.team << [user, :master] - - sign_in(user) - end describe 'GET diff' do + let(:user) { create(:user) } + render_views def do_get(opts = {}) @@ -20,6 +15,12 @@ describe Projects::BlobController do get :diff, params.merge(opts) end + before do + project.team << [user, :master] + + sign_in(user) + end + context 'when essential params are missing' do it 'renders nothing' do do_get @@ -37,7 +38,69 @@ describe Projects::BlobController do end end + describe 'GET edit' do + let(:default_params) do + { + namespace_id: project.namespace, + project_id: project, + id: 'master/CHANGELOG' + } + end + + context 'anonymous' do + before do + get :edit, default_params + end + + it 'redirects to sign in and returns' do + expect(response).to redirect_to(new_user_session_path) + end + end + + context 'as guest' do + let(:guest) { create(:user) } + + before do + sign_in(guest) + get :edit, default_params + end + + it 'redirects to blob show' do + expect(response).to redirect_to(namespace_project_blob_path(project.namespace, project, 'master/CHANGELOG')) + end + end + + context 'as developer' do + let(:developer) { create(:user) } + + before do + project.team << [developer, :developer] + sign_in(developer) + get :edit, default_params + end + + it 'redirects to blob show' do + expect(response).to have_http_status(200) + end + end + + context 'as master' do + let(:master) { create(:user) } + + before do + project.team << [master, :master] + sign_in(master) + get :edit, default_params + end + + it 'redirects to blob show' do + expect(response).to have_http_status(200) + end + end + end + describe 'PUT update' do + let(:user) { create(:user) } let(:default_params) do { namespace_id: project.namespace, @@ -53,6 +116,12 @@ describe Projects::BlobController do namespace_project_blob_path(project.namespace, project, 'master/CHANGELOG') end + before do + project.team << [user, :master] + + sign_in(user) + end + it 'redirects to blob' do put :update, default_params diff --git a/spec/controllers/projects/commit_controller_spec.rb b/spec/controllers/projects/commit_controller_spec.rb index b223a22ae60..69e4706dc71 100644 --- a/spec/controllers/projects/commit_controller_spec.rb +++ b/spec/controllers/projects/commit_controller_spec.rb @@ -266,8 +266,8 @@ describe Projects::CommitController do diff_for_path(id: commit2.id, old_path: existing_path, new_path: existing_path) expect(assigns(:diff_notes_disabled)).to be_falsey - expect(assigns(:comments_target)).to eq(noteable_type: 'Commit', - commit_id: commit2.id) + expect(assigns(:new_diff_note_attrs)).to eq(noteable_type: 'Commit', + commit_id: commit2.id) end it 'only renders the diffs for the path given' do diff --git a/spec/controllers/projects/discussions_controller_spec.rb b/spec/controllers/projects/discussions_controller_spec.rb index 79ab364a6f3..fe62898fa9b 100644 --- a/spec/controllers/projects/discussions_controller_spec.rb +++ b/spec/controllers/projects/discussions_controller_spec.rb @@ -4,7 +4,7 @@ describe Projects::DiscussionsController do let(:user) { create(:user) } let(:merge_request) { create(:merge_request) } let(:project) { merge_request.source_project } - let(:note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project) } + let(:note) { create(:discussion_note_on_merge_request, noteable: merge_request, project: project) } let(:discussion) { note.discussion } let(:request_params) do diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index d5f1d46bf7f..79034b8d24d 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -519,7 +519,7 @@ describe Projects::IssuesController do end context 'resolving discussions in MergeRequest' do - let(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request)]).first } + let(:discussion) { create(:diff_note_on_merge_request).to_discussion } let(:merge_request) { discussion.noteable } let(:project) { merge_request.source_project } diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index 99d5583e683..1739d40ab88 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -586,8 +586,8 @@ describe Projects::MergeRequestsController do diff_for_path(id: merge_request.iid, old_path: existing_path, new_path: existing_path) expect(assigns(:diff_notes_disabled)).to be_falsey - expect(assigns(:comments_target)).to eq(noteable_type: 'MergeRequest', - noteable_id: merge_request.id) + expect(assigns(:new_diff_note_attrs)).to eq(noteable_type: 'MergeRequest', + noteable_id: merge_request.id) end it 'only renders the diffs for the path given' do diff --git a/spec/controllers/projects/notes_controller_spec.rb b/spec/controllers/projects/notes_controller_spec.rb index d80780b1d90..f140eaef5d5 100644 --- a/spec/controllers/projects/notes_controller_spec.rb +++ b/spec/controllers/projects/notes_controller_spec.rb @@ -14,6 +14,109 @@ describe Projects::NotesController do } end + describe 'GET index' do + let(:request_params) do + { + namespace_id: project.namespace, + project_id: project, + target_type: 'issue', + target_id: issue.id, + format: 'json' + } + end + + let(:parsed_response) { JSON.parse(response.body).with_indifferent_access } + let(:note_json) { parsed_response[:notes].first } + + before do + sign_in(user) + project.team << [user, :developer] + end + + it 'passes last_fetched_at from headers to NotesFinder' do + last_fetched_at = 3.hours.ago.to_i + + request.headers['X-Last-Fetched-At'] = last_fetched_at + + expect(NotesFinder).to receive(:new) + .with(anything, anything, hash_including(last_fetched_at: last_fetched_at)) + .and_call_original + + get :index, request_params + end + + context 'for a discussion note' do + let!(:note) { create(:discussion_note_on_issue, noteable: issue, project: project) } + + it 'responds with the expected attributes' do + get :index, request_params + + expect(note_json[:id]).to eq(note.id) + expect(note_json[:discussion_html]).not_to be_nil + expect(note_json[:diff_discussion_html]).to be_nil + end + end + + context 'for a diff discussion note' do + let(:project) { create(:project, :repository) } + let!(:note) { create(:diff_note_on_merge_request, project: project) } + + let(:params) { request_params.merge(target_type: 'merge_request', target_id: note.noteable_id) } + + it 'responds with the expected attributes' do + get :index, params + + expect(note_json[:id]).to eq(note.id) + expect(note_json[:discussion_html]).not_to be_nil + expect(note_json[:diff_discussion_html]).not_to be_nil + end + end + + context 'for a commit note' do + let(:project) { create(:project, :repository) } + let!(:note) { create(:note_on_commit, project: project) } + + context 'when displayed on a merge request' do + let(:merge_request) { create(:merge_request, source_project: project) } + + let(:params) { request_params.merge(target_type: 'merge_request', target_id: merge_request.id) } + + it 'responds with the expected attributes' do + get :index, params + + expect(note_json[:id]).to eq(note.id) + expect(note_json[:discussion_html]).not_to be_nil + expect(note_json[:diff_discussion_html]).to be_nil + end + end + + context 'when displayed on the commit' do + let(:params) { request_params.merge(target_type: 'commit', target_id: note.commit_id) } + + it 'responds with the expected attributes' do + get :index, params + + expect(note_json[:id]).to eq(note.id) + expect(note_json[:discussion_html]).to be_nil + expect(note_json[:diff_discussion_html]).to be_nil + end + end + end + + context 'for a regular note' do + let!(:note) { create(:note, noteable: issue, project: project) } + + it 'responds with the expected attributes' do + get :index, request_params + + expect(note_json[:id]).to eq(note.id) + expect(note_json[:html]).not_to be_nil + expect(note_json[:discussion_html]).to be_nil + expect(note_json[:diff_discussion_html]).to be_nil + end + end + end + describe 'POST create' do let(:merge_request) { create(:merge_request) } let(:project) { merge_request.source_project } @@ -49,7 +152,8 @@ describe Projects::NotesController do note: 'some note', noteable_id: merge_request.id.to_s, noteable_type: 'MergeRequest', - merge_request_diff_head_sha: 'sha' + merge_request_diff_head_sha: 'sha', + in_reply_to_discussion_id: nil } expect(Notes::CreateService).to receive(:new).with(project, user, service_params).and_return(double(execute: true)) @@ -200,31 +304,4 @@ describe Projects::NotesController do end end end - - describe 'GET index' do - let(:last_fetched_at) { '1487756246' } - let(:request_params) do - { - namespace_id: project.namespace, - project_id: project, - target_type: 'issue', - target_id: issue.id - } - end - - before do - sign_in(user) - project.team << [user, :developer] - end - - it 'passes last_fetched_at from headers to NotesFinder' do - request.headers['X-Last-Fetched-At'] = last_fetched_at - - expect(NotesFinder).to receive(:new) - .with(anything, anything, hash_including(last_fetched_at: last_fetched_at)) - .and_call_original - - get :index, request_params - end - end end diff --git a/spec/controllers/projects/registry/repositories_controller_spec.rb b/spec/controllers/projects/registry/repositories_controller_spec.rb new file mode 100644 index 00000000000..464302824a8 --- /dev/null +++ b/spec/controllers/projects/registry/repositories_controller_spec.rb @@ -0,0 +1,84 @@ +require 'spec_helper' + +describe Projects::Registry::RepositoriesController do + let(:user) { create(:user) } + let(:project) { create(:empty_project, :private) } + + before do + sign_in(user) + stub_container_registry_config(enabled: true) + end + + context 'when user has access to registry' do + before do + project.add_developer(user) + end + + describe 'GET index' do + context 'when root container repository exists' do + before do + create(:container_repository, :root, project: project) + end + + it 'does not create root container repository' do + expect { go_to_index }.not_to change { ContainerRepository.all.count } + end + end + + context 'when root container repository is not created' do + context 'when there are tags for this repository' do + before do + stub_container_registry_tags(repository: project.full_path, + tags: %w[rc1 latest]) + end + + it 'successfully renders container repositories' do + go_to_index + + expect(response).to have_http_status(:ok) + end + + it 'creates a root container repository' do + expect { go_to_index }.to change { ContainerRepository.all.count }.by(1) + expect(ContainerRepository.first).to be_root_repository + end + end + + context 'when there are no tags for this repository' do + before do + stub_container_registry_tags(repository: :any, tags: []) + end + + it 'successfully renders container repositories' do + go_to_index + + expect(response).to have_http_status(:ok) + end + + it 'does not ensure root container repository' do + expect { go_to_index }.not_to change { ContainerRepository.all.count } + end + end + end + end + end + + context 'when user does not have access to registry' do + describe 'GET index' do + it 'responds with 404' do + go_to_index + + expect(response).to have_http_status(:not_found) + end + + it 'does not ensure root container repository' do + expect { go_to_index }.not_to change { ContainerRepository.all.count } + end + end + end + + def go_to_index + get :index, namespace_id: project.namespace, + project_id: project + end +end diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb index 87a0c95c4dc..b62def83ee4 100644 --- a/spec/factories/ci/builds.rb +++ b/spec/factories/ci/builds.rb @@ -111,7 +111,7 @@ FactoryGirl.define do trait :trace do after(:create) do |build, evaluator| - build.trace = 'BUILD TRACE' + build.trace.set('BUILD TRACE') end end diff --git a/spec/factories/ci/trigger_schedules.rb b/spec/factories/ci/trigger_schedules.rb new file mode 100644 index 00000000000..315bce16995 --- /dev/null +++ b/spec/factories/ci/trigger_schedules.rb @@ -0,0 +1,26 @@ +FactoryGirl.define do + factory :ci_trigger_schedule, class: Ci::TriggerSchedule do + trigger factory: :ci_trigger_for_trigger_schedule + cron '0 1 * * *' + cron_timezone Gitlab::Ci::CronParser::VALID_SYNTAX_SAMPLE_TIME_ZONE + + after(:build) do |trigger_schedule, evaluator| + trigger_schedule.update!(project: trigger_schedule.trigger.project) + end + + trait :nightly do + cron '0 1 * * *' + cron_timezone Gitlab::Ci::CronParser::VALID_SYNTAX_SAMPLE_TIME_ZONE + end + + trait :weekly do + cron '0 1 * * 6' + cron_timezone Gitlab::Ci::CronParser::VALID_SYNTAX_SAMPLE_TIME_ZONE + end + + trait :monthly do + cron '0 1 22 * *' + cron_timezone Gitlab::Ci::CronParser::VALID_SYNTAX_SAMPLE_TIME_ZONE + end + end +end diff --git a/spec/factories/ci/triggers.rb b/spec/factories/ci/triggers.rb index a27b04424e5..2295455575d 100644 --- a/spec/factories/ci/triggers.rb +++ b/spec/factories/ci/triggers.rb @@ -2,6 +2,13 @@ FactoryGirl.define do factory :ci_trigger_without_token, class: Ci::Trigger do factory :ci_trigger do token 'token' + + factory :ci_trigger_for_trigger_schedule do + token { SecureRandom.hex(15) } + owner factory: :user + project factory: :project + ref 'master' + end end end end diff --git a/spec/factories/container_repositories.rb b/spec/factories/container_repositories.rb new file mode 100644 index 00000000000..3fcad9fd4b3 --- /dev/null +++ b/spec/factories/container_repositories.rb @@ -0,0 +1,33 @@ +FactoryGirl.define do + factory :container_repository do + name 'test_container_image' + project + + transient do + tags [] + end + + trait :root do + name '' + end + + after(:build) do |repository, evaluator| + next if evaluator.tags.to_a.none? + + allow(repository.client) + .to receive(:repository_tags) + .and_return({ + 'name' => repository.path, + 'tags' => evaluator.tags + }) + + evaluator.tags.each do |tag| + allow(repository.client) + .to receive(:repository_tag_digest) + .with(repository.path, tag) + .and_return('sha256:4c8e63ca4cb663ce6c688cb06f1c3' \ + '72b088dac5b6d7ad7d49cd620d85cf72a15') + end + end + end +end diff --git a/spec/factories/notes.rb b/spec/factories/notes.rb index fe19a404e16..90c35e2c7f8 100644 --- a/spec/factories/notes.rb +++ b/spec/factories/notes.rb @@ -16,10 +16,21 @@ FactoryGirl.define do factory :note_on_personal_snippet, traits: [:on_personal_snippet] factory :system_note, traits: [:system] - factory :legacy_diff_note_on_commit, traits: [:on_commit, :legacy_diff_note], class: LegacyDiffNote do + factory :discussion_note_on_merge_request, traits: [:on_merge_request], class: DiscussionNote do association :project, :repository + + trait :resolved do + resolved_at { Time.now } + resolved_by { create(:user) } + end end + factory :discussion_note_on_issue, traits: [:on_issue], class: DiscussionNote + + factory :discussion_note_on_commit, traits: [:on_commit], class: DiscussionNote + + factory :legacy_diff_note_on_commit, traits: [:on_commit, :legacy_diff_note], class: LegacyDiffNote + factory :legacy_diff_note_on_merge_request, traits: [:on_merge_request, :legacy_diff_note], class: LegacyDiffNote do association :project, :repository end @@ -37,7 +48,7 @@ FactoryGirl.define do new_path: "files/ruby/popen.rb", old_line: nil, new_line: line_number, - diff_refs: noteable.diff_refs + diff_refs: noteable.try(:diff_refs) ) end @@ -108,5 +119,18 @@ FactoryGirl.define do trait :with_svg_attachment do attachment { fixture_file_upload(Rails.root + "spec/fixtures/unsanitized.svg", "image/svg+xml") } end + + transient do + in_reply_to nil + end + + before(:create) do |note, evaluator| + discussion = evaluator.in_reply_to + next unless discussion + discussion = discussion.to_discussion if discussion.is_a?(Note) + next unless discussion + + note.assign_attributes(discussion.reply_attributes.merge(project: discussion.project)) + end end end diff --git a/spec/factories/sent_notifications.rb b/spec/factories/sent_notifications.rb index 6287c40afe9..99253be5a22 100644 --- a/spec/factories/sent_notifications.rb +++ b/spec/factories/sent_notifications.rb @@ -2,7 +2,7 @@ FactoryGirl.define do factory :sent_notification do project factory: :empty_project recipient factory: :user - noteable factory: :issue - reply_key "0123456789abcdef" * 2 + noteable { create(:issue, project: project) } + reply_key { SentNotification.reply_key } end end diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb index e168585534d..30ad169e30e 100644 --- a/spec/features/boards/boards_spec.rb +++ b/spec/features/boards/boards_spec.rb @@ -590,7 +590,7 @@ describe 'Issue Boards', feature: true, js: true do end def click_filter_link(link_text) - page.within('.filtered-search-input-container') do + page.within('.filtered-search-box') do expect(page).to have_button(link_text) click_button(link_text) diff --git a/spec/features/boards/modal_filter_spec.rb b/spec/features/boards/modal_filter_spec.rb index e2281a7da55..4a4c13e79c8 100644 --- a/spec/features/boards/modal_filter_spec.rb +++ b/spec/features/boards/modal_filter_spec.rb @@ -219,7 +219,7 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do end def click_filter_link(link_text) - page.within('.add-issues-modal .filtered-search-input-container') do + page.within('.add-issues-modal .filtered-search-box') do expect(page).to have_button(link_text) click_button(link_text) diff --git a/spec/features/container_registry_spec.rb b/spec/features/container_registry_spec.rb index 203e55a36f2..b86609e07c5 100644 --- a/spec/features/container_registry_spec.rb +++ b/spec/features/container_registry_spec.rb @@ -1,45 +1,61 @@ require 'spec_helper' describe "Container Registry" do + let(:user) { create(:user) } let(:project) { create(:empty_project) } - let(:repository) { project.container_registry_repository } - let(:tag_name) { 'latest' } - let(:tags) { [tag_name] } + + let(:container_repository) do + create(:container_repository, name: 'my/image') + end before do - login_as(:user) - project.team << [@user, :developer] - stub_container_registry_tags(*tags) + login_as(user) + project.add_developer(user) stub_container_registry_config(enabled: true) - allow(Auth::ContainerRegistryAuthenticationService).to receive(:full_access_token).and_return('token') + stub_container_registry_tags(repository: :any, tags: []) end - describe 'GET /:project/container_registry' do + context 'when there are no image repositories' do + scenario 'user visits container registry main page' do + visit_container_registry + + expect(page).to have_content 'No container image repositories' + end + end + + context 'when there are image repositories' do before do - visit namespace_project_container_registry_index_path(project.namespace, project) + stub_container_registry_tags(repository: %r{my/image}, tags: %w[latest]) + project.container_repositories << container_repository end - context 'when no tags' do - let(:tags) { [] } + scenario 'user wants to see multi-level container repository' do + visit_container_registry - it { expect(page).to have_content('No images in Container Registry for this project') } + expect(page).to have_content('my/image') end - context 'when there are tags' do - it { expect(page).to have_content(tag_name) } - it { expect(page).to have_content('d7a513a66') } - end - end + scenario 'user removes entire container repository' do + visit_container_registry - describe 'DELETE /:project/container_registry/tag' do - before do - visit namespace_project_container_registry_index_path(project.namespace, project) + expect_any_instance_of(ContainerRepository) + .to receive(:delete_tags!).and_return(true) + + click_on 'Remove repository' end - it do - expect_any_instance_of(::ContainerRegistry::Tag).to receive(:delete).and_return(true) + scenario 'user removes a specific tag from container repository' do + visit_container_registry - click_on 'Remove' + expect_any_instance_of(ContainerRegistry::Tag) + .to receive(:delete).and_return(true) + + click_on 'Remove tag' end end + + def visit_container_registry + visit namespace_project_container_registry_index_path( + project.namespace, project) + end end diff --git a/spec/features/dashboard/project_member_activity_index_spec.rb b/spec/features/dashboard/project_member_activity_index_spec.rb index 49d93db58a9..d62839a09ef 100644 --- a/spec/features/dashboard/project_member_activity_index_spec.rb +++ b/spec/features/dashboard/project_member_activity_index_spec.rb @@ -21,20 +21,20 @@ feature 'Project member activity', feature: true, js: true do context 'when a user joins the project' do before { visit_activities_and_wait_with_event(Event::JOINED) } - it { is_expected.to eq("#{user.name} joined project") } + it { is_expected.to eq("joined project") } end context 'when a user leaves the project' do before { visit_activities_and_wait_with_event(Event::LEFT) } - it { is_expected.to eq("#{user.name} left project") } + it { is_expected.to eq("left project") } end context 'when a users membership expires for the project' do before { visit_activities_and_wait_with_event(Event::EXPIRED) } it "presents the correct message" do - message = "#{user.name} removed due to membership expiration from project" + message = "removed due to membership expiration from project" is_expected.to eq(message) end end diff --git a/spec/features/discussion_comments_spec.rb b/spec/features/discussion_comments_spec.rb new file mode 100644 index 00000000000..3ffe88e4c59 --- /dev/null +++ b/spec/features/discussion_comments_spec.rb @@ -0,0 +1,261 @@ +require 'spec_helper' + +shared_examples 'discussion comments' do |resource_name| + let(:form_selector) { '.js-main-target-form' } + let(:dropdown_selector) { "#{form_selector} .comment-type-dropdown" } + let(:toggle_selector) { "#{dropdown_selector} .dropdown-toggle" } + let(:menu_selector) { "#{dropdown_selector} .dropdown-menu" } + let(:submit_selector) { "#{form_selector} .js-comment-submit-button" } + let(:close_selector) { "#{form_selector} .btn-comment-and-close" } + + it 'should show a comment type toggle' do + expect(page).to have_selector toggle_selector + end + + it '"Comment" will post a comment' do + find("#{form_selector} .note-textarea").send_keys('a') + + find(submit_selector).click + + find('.timeline .timeline-entry', match: :first) + new_comment = all('.timeline .timeline-entry').last + + expect(new_comment).to have_content 'a' + expect(new_comment).not_to have_selector '.discussion' + end + + if resource_name == 'issue' + it "'Comment & close #{resource_name}' will post a comment and close the #{resource_name}" do + find("#{form_selector} .note-textarea").send_keys('a') + + find(close_selector).click + + find('.timeline .timeline-entry', match: :first) + entries = all('.timeline .timeline-entry') + close_note = entries.last + new_comment = entries[-2] + + expect(close_note).to have_content 'closed' + expect(new_comment).not_to have_selector '.discussion' + end + end + + describe 'when the toggle is clicked' do + before do + find("#{form_selector} .note-textarea").send_keys('a') + + find(toggle_selector).click + end + + it 'opens a comment type dropdown with "Comment" and "Start discussion"' do + expect(page).to have_selector menu_selector + end + + it 'has a "Comment" item' do + menu = find(menu_selector) + + expect(menu).to have_content 'Comment' + expect(menu).to have_content "Add a general comment to this #{resource_name}." + end + + it 'has a "Start discussion" item' do + menu = find(menu_selector) + + expect(menu).to have_content 'Start discussion' + expect(menu).to have_content 'Discuss a specific suggestion or question that needs to be resolved.' + end + + it 'has the "Comment" item selected by default' do + find("#{menu_selector} li", match: :first) + items = all("#{menu_selector} li") + + expect(items.first).to have_content 'Comment' + expect(items.first).to have_selector '.fa-check' + expect(items.first['class']).to match 'droplab-item-selected' + + expect(items.last).to have_content 'Start discussion' + expect(items.last).not_to have_selector '.fa-check' + expect(items.last['class']).not_to match 'droplab-item-selected' + end + + it 'closes the menu when clicking the toggle' do + find(toggle_selector).click + + expect(page).not_to have_selector menu_selector + end + + it 'closes the menu when clicking the body' do + find('body').click + + expect(page).not_to have_selector menu_selector + end + + describe 'when selecting "Start discussion"' do + before do + screenshot_and_open_image + find("#{menu_selector} li", match: :first) + p first("#{menu_selector} li")['class'] + p first("#{menu_selector} li").text + first("#{menu_selector} li").click + end + + it 'updates the note_type input to "DiscussionNote"' do + expect(find("#{form_selector} #note_type", visible: false).value).to be 'DiscussionNote' + end + + it 'updates the submit button text' do + expect(find(dropdown_selector)).to have_content "Start discussion" + end + + if resource_name =~ /(issue|merge request)/ + it 'updates the close button text' do + expect(find(close_selector)).to have_content "Start discussion & close #{resource_name}" + end + end + + it 'closes the dropdown' do + expect(page).not_to have_selector menu_selector + end + + it '"Start discussion" will post a discussion' do + find(submit_selector).click + + find('.timeline .timeline-entry', match: :first) + new_comment = all('.timeline .timeline-entry').last + + expect(new_comment).to have_content 'a' + expect(new_comment).to have_selector '.discussion' + end + + if resource_name == 'issue' + it "'Start discussion & close #{resource_name}' will post a discussion and close the #{resource_name}" do + find(close_selector).click + + find('.timeline .timeline-entry', match: :first) + entries = all('.timeline .timeline-entry') + close_note = entries.last + new_discussion = entries[-2] + + expect(close_note).to have_content 'closed' + expect(new_discussion).to have_selector '.discussion' + end + end + + describe 'when opening the menu' do + before do + find(toggle_selector).click + end + + it 'should have "Start discussion" selected' do + find("#{menu_selector} li", match: :first) + items = all("#{menu_selector} li") + + expect(items.first).to have_content 'Comment' + expect(items.first).not_to have_selector '.fa-check' + expect(items.first).not_to have_selector '.droplab-item-selected' + + expect(items.last).to have_content 'Start discussion' + expect(items.last).to have_selector '.fa-check' + expect(items.last).to have_selector '.droplab-item-selected' + end + + describe 'when selecting "Comment"' do + before do + find("#{menu_selector} li", match: :first) + all("#{menu_selector} li").last.click + end + + it 'clears the note_type input"' do + expect(find("#{form_selector} #note_type").value).to be '' + end + + it 'updates the submit button text' do + expect(find(dropdown_selector)).to have_content "Comment" + end + + if resource_name =~ /(issue|merge request)/ + it 'updates the close button text' do + expect(find(close_selector)).to have_content "Comment & close #{resource_name}" + end + end + + it 'closes the dropdown' do + expect(page).not_to have_selector menu_selector + end + + it 'should have "Comment" selected when opening the menu' do + find(toggle_selector).click + + find("#{menu_selector} li", match: :first) + items = all("#{menu_selector} li") + + expect(items.first).to have_content 'Comment' + expect(items.first).to have_selector '.fa-check' + expect(items.first).to have_selector '.droplab-item-selected' + + expect(items.last).to have_content 'Start discussion' + expect(items.last).not_to have_selector '.fa-check' + expect(items.last).not_to have_selector '.droplab-item-selected' + end + end + end + end + end + + if resource_name =~ /(issue|merge request)/ + describe "on a closed #{resource_name}" do + + end + end +end + +describe 'Discussion Comments', :feature, :js do + include RepoHelpers + + let(:user) { create(:user) } + let(:project) { create(:project) } + + before do + project.team << [user, :developer] + + login_as(user) + end + + describe 'on a merge request' do + let(:merge_request) { create(:merge_request, source_project: project) } + + before do + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + it_behaves_like 'discussion comments', 'merge request' + end + + describe 'on an issue' do + let(:issue) { create(:issue, project: project) } + + before do + visit namespace_project_issue_path(project.namespace, project, issue) + end + + it_behaves_like 'discussion comments', 'issue' + end + + describe 'on an snippet' do + let(:snippet) { create(:personal_snippet, :public, author: user) } + + before do + visit snippet_path(snippet) + end + + it_behaves_like 'discussion comments', 'snippet' + end + + describe 'on a commit' do + before do + visit namespace_project_commit_path(project.namespace, project, sample_commit.id) + end + + it_behaves_like 'discussion comments', 'commit' + end +end diff --git a/spec/features/groups_spec.rb b/spec/features/groups_spec.rb index c90cc06a8f5..8bfe6f4d54b 100644 --- a/spec/features/groups_spec.rb +++ b/spec/features/groups_spec.rb @@ -86,17 +86,40 @@ feature 'Group', feature: true do describe 'create a nested group' do let(:group) { create(:group, path: 'foo') } - before do - visit subgroups_group_path(group) - click_link 'New Subgroup' + context 'as admin' do + before do + visit subgroups_group_path(group) + click_link 'New Subgroup' + end + + it 'creates a nested group' do + fill_in 'Group path', with: 'bar' + click_button 'Create group' + + expect(current_path).to eq(group_path('foo/bar')) + expect(page).to have_content("Group 'bar' was successfully created.") + end end - it 'creates a nested group' do - fill_in 'Group path', with: 'bar' - click_button 'Create group' + context 'as group owner' do + let(:user) { create(:user) } - expect(current_path).to eq(group_path('foo/bar')) - expect(page).to have_content("Group 'bar' was successfully created.") + before do + group.add_owner(user) + logout + login_as(user) + + visit subgroups_group_path(group) + click_link 'New Subgroup' + end + + it 'creates a nested group' do + fill_in 'Group path', with: 'bar' + click_button 'Create group' + + expect(current_path).to eq(group_path('foo/bar')) + expect(page).to have_content("Group 'bar' was successfully created.") + end end end diff --git a/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb b/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb index 572bca3de21..58f897cba3e 100644 --- a/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb +++ b/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb @@ -4,7 +4,7 @@ feature 'Resolving all open discussions in a merge request from an issue', featu let(:user) { create(:user) } let(:project) { create(:project) } let(:merge_request) { create(:merge_request, source_project: project) } - let!(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request, noteable: merge_request, project: project)]).first } + let!(:discussion) { create(:diff_note_on_merge_request, noteable: merge_request, project: project).to_discussion } describe 'as a user with access to the project' do before do diff --git a/spec/features/issues/filtered_search/dropdown_assignee_spec.rb b/spec/features/issues/filtered_search/dropdown_assignee_spec.rb index 4dcc56a97d1..3d1a9ed1722 100644 --- a/spec/features/issues/filtered_search/dropdown_assignee_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_assignee_spec.rb @@ -194,7 +194,7 @@ describe 'Dropdown assignee', :feature, :js do new_user = create(:user) project.team << [new_user, :master] - find('.filtered-search-input-container .clear-search').click + find('.filtered-search-box .clear-search').click filtered_search.set('assignee') filtered_search.send_keys(':') diff --git a/spec/features/issues/filtered_search/dropdown_author_spec.rb b/spec/features/issues/filtered_search/dropdown_author_spec.rb index 1772a120045..990e3b3e60c 100644 --- a/spec/features/issues/filtered_search/dropdown_author_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_author_spec.rb @@ -172,7 +172,7 @@ describe 'Dropdown author', js: true, feature: true do new_user = create(:user) project.team << [new_user, :master] - find('.filtered-search-input-container .clear-search').click + find('.filtered-search-box .clear-search').click filtered_search.set('author') send_keys_to_filtered_search(':') diff --git a/spec/features/issues/filtered_search/dropdown_label_spec.rb b/spec/features/issues/filtered_search/dropdown_label_spec.rb index d7d71b0eba9..abe5d61e38c 100644 --- a/spec/features/issues/filtered_search/dropdown_label_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_label_spec.rb @@ -29,7 +29,7 @@ describe 'Dropdown label', js: true, feature: true do end def clear_search_field - find('.filtered-search-input-container .clear-search').click + find('.filtered-search-box .clear-search').click end before do diff --git a/spec/features/issues/filtered_search/dropdown_milestone_spec.rb b/spec/features/issues/filtered_search/dropdown_milestone_spec.rb index cf5259b878d..448259057b0 100644 --- a/spec/features/issues/filtered_search/dropdown_milestone_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_milestone_spec.rb @@ -252,7 +252,7 @@ describe 'Dropdown milestone', :feature, :js do expect(initial_size).to be > 0 create(:milestone, project: project) - find('.filtered-search-input-container .clear-search').click + find('.filtered-search-box .clear-search').click filtered_search.set('milestone:') expect(dropdown_milestone_size).to eq(initial_size) diff --git a/spec/features/issues/filtered_search/filter_issues_spec.rb b/spec/features/issues/filtered_search/filter_issues_spec.rb index 2f880c926e7..6f00066de4d 100644 --- a/spec/features/issues/filtered_search/filter_issues_spec.rb +++ b/spec/features/issues/filtered_search/filter_issues_spec.rb @@ -758,10 +758,10 @@ describe 'Filter issues', js: true, feature: true do expect_issues_list_count(2) - sort_toggle = find('.filtered-search-container .dropdown-toggle') + sort_toggle = find('.filtered-search-wrapper .dropdown-toggle') sort_toggle.click - find('.filtered-search-container .dropdown-menu li a', text: 'Oldest updated').click + find('.filtered-search-wrapper .dropdown-menu li a', text: 'Oldest updated').click wait_for_ajax expect(find('.issues-list .issue:first-of-type .issue-title-text a')).to have_content(old_issue.title) diff --git a/spec/features/issues/filtered_search/recent_searches_spec.rb b/spec/features/issues/filtered_search/recent_searches_spec.rb new file mode 100644 index 00000000000..f506065a242 --- /dev/null +++ b/spec/features/issues/filtered_search/recent_searches_spec.rb @@ -0,0 +1,92 @@ +require 'spec_helper' + +describe 'Recent searches', js: true, feature: true do + include FilteredSearchHelpers + include WaitForAjax + + let!(:group) { create(:group) } + let!(:project) { create(:project, group: group) } + let!(:user) { create(:user) } + + before do + Capybara.ignore_hidden_elements = false + project.add_master(user) + group.add_developer(user) + create(:issue, project: project) + login_as(user) + + remove_recent_searches + end + + after do + Capybara.ignore_hidden_elements = true + end + + it 'searching adds to recent searches' do + visit namespace_project_issues_path(project.namespace, project) + + input_filtered_search('foo', submit: true) + input_filtered_search('bar', submit: true) + + items = all('.filtered-search-history-dropdown-item', visible: false) + + expect(items.count).to eq(2) + expect(items[0].text).to eq('bar') + expect(items[1].text).to eq('foo') + end + + it 'visiting URL with search params adds to recent searches' do + visit namespace_project_issues_path(project.namespace, project, label_name: 'foo', search: 'bar') + visit namespace_project_issues_path(project.namespace, project, label_name: 'qux', search: 'garply') + + items = all('.filtered-search-history-dropdown-item', visible: false) + + expect(items.count).to eq(2) + expect(items[0].text).to eq('label:~qux garply') + expect(items[1].text).to eq('label:~foo bar') + end + + it 'saved recent searches are restored last on the list' do + set_recent_searches('["saved1", "saved2"]') + + visit namespace_project_issues_path(project.namespace, project, search: 'foo') + + items = all('.filtered-search-history-dropdown-item', visible: false) + + expect(items.count).to eq(3) + expect(items[0].text).to eq('foo') + expect(items[1].text).to eq('saved1') + expect(items[2].text).to eq('saved2') + end + + it 'clicking item fills search input' do + set_recent_searches('["foo", "bar"]') + visit namespace_project_issues_path(project.namespace, project) + + all('.filtered-search-history-dropdown-item', visible: false)[0].trigger('click') + wait_for_filtered_search('foo') + + expect(find('.filtered-search').value.strip).to eq('foo') + end + + it 'clear recent searches button, clears recent searches' do + set_recent_searches('["foo"]') + visit namespace_project_issues_path(project.namespace, project) + + items_before = all('.filtered-search-history-dropdown-item', visible: false) + + expect(items_before.count).to eq(1) + + find('.filtered-search-history-clear-button', visible: false).trigger('click') + items_after = all('.filtered-search-history-dropdown-item', visible: false) + + expect(items_after.count).to eq(0) + end + + it 'shows flash error when failed to parse saved history' do + set_recent_searches('fail') + visit namespace_project_issues_path(project.namespace, project) + + expect(find('.flash-alert')).to have_text('An error occured while parsing recent searches') + end +end diff --git a/spec/features/issues/filtered_search/search_bar_spec.rb b/spec/features/issues/filtered_search/search_bar_spec.rb index 3b2b6347bc0..28137f11b92 100644 --- a/spec/features/issues/filtered_search/search_bar_spec.rb +++ b/spec/features/issues/filtered_search/search_bar_spec.rb @@ -44,7 +44,7 @@ describe 'Search bar', js: true, feature: true do filtered_search.set(search_text) expect(filtered_search.value).to eq(search_text) - find('.filtered-search-input-container .clear-search').click + find('.filtered-search-box .clear-search').click expect(filtered_search.value).to eq('') end @@ -55,7 +55,7 @@ describe 'Search bar', js: true, feature: true do it 'hides after clicked' do filtered_search.set('a') - find('.filtered-search-input-container .clear-search').click + find('.filtered-search-box .clear-search').click expect(page).to have_css('.clear-search', visible: false) end @@ -81,7 +81,7 @@ describe 'Search bar', js: true, feature: true do expect(find('#js-dropdown-hint')).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1) - find('.filtered-search-input-container .clear-search').click + find('.filtered-search-box .clear-search').click filtered_search.click expect(find('#js-dropdown-hint')).to have_selector('.filter-dropdown .filter-dropdown-item', count: original_size) @@ -98,7 +98,7 @@ describe 'Search bar', js: true, feature: true do find('#js-dropdown-hint', visible: false) - find('.filtered-search-input-container .clear-search').click + find('.filtered-search-box .clear-search').click filtered_search.click expect(find('#js-dropdown-hint')).to have_selector('.filter-dropdown .filter-dropdown-item', count: 4) diff --git a/spec/features/login_spec.rb b/spec/features/login_spec.rb index f32d1f78b40..11d417c253d 100644 --- a/spec/features/login_spec.rb +++ b/spec/features/login_spec.rb @@ -199,52 +199,125 @@ feature 'Login', feature: true do describe 'with required two-factor authentication enabled' do let(:user) { create(:user) } - before(:each) { stub_application_setting(require_two_factor_authentication: true) } + # TODO: otp_grace_period_started_at - context 'with grace period defined' do - before(:each) do - stub_application_setting(two_factor_grace_period: 48) - login_with(user) - end + context 'global setting' do + before(:each) { stub_application_setting(require_two_factor_authentication: true) } - context 'within the grace period' do - it 'redirects to two-factor configuration page' do - expect(current_path).to eq profile_two_factor_auth_path - expect(page).to have_content('You must enable Two-Factor Authentication for your account before') + context 'with grace period defined' do + before(:each) do + stub_application_setting(two_factor_grace_period: 48) + login_with(user) end - it 'allows skipping two-factor configuration', js: true do - expect(current_path).to eq profile_two_factor_auth_path + context 'within the grace period' do + it 'redirects to two-factor configuration page' do + expect(current_path).to eq profile_two_factor_auth_path + expect(page).to have_content('The global settings require you to enable Two-Factor Authentication for your account. You need to do this before ') + end - click_link 'Configure it later' - expect(current_path).to eq root_path + it 'allows skipping two-factor configuration', js: true do + expect(current_path).to eq profile_two_factor_auth_path + + click_link 'Configure it later' + expect(current_path).to eq root_path + end end - end - context 'after the grace period' do - let(:user) { create(:user, otp_grace_period_started_at: 9999.hours.ago) } + context 'after the grace period' do + let(:user) { create(:user, otp_grace_period_started_at: 9999.hours.ago) } - it 'redirects to two-factor configuration page' do - expect(current_path).to eq profile_two_factor_auth_path - expect(page).to have_content('You must enable Two-Factor Authentication for your account.') + it 'redirects to two-factor configuration page' do + expect(current_path).to eq profile_two_factor_auth_path + expect(page).to have_content( + 'The global settings require you to enable Two-Factor Authentication for your account.' + ) + end + + it 'disallows skipping two-factor configuration', js: true do + expect(current_path).to eq profile_two_factor_auth_path + expect(page).not_to have_link('Configure it later') + end + end + end + + context 'without grace period defined' do + before(:each) do + stub_application_setting(two_factor_grace_period: 0) + login_with(user) end - it 'disallows skipping two-factor configuration', js: true do + it 'redirects to two-factor configuration page' do expect(current_path).to eq profile_two_factor_auth_path - expect(page).not_to have_link('Configure it later') + expect(page).to have_content( + 'The global settings require you to enable Two-Factor Authentication for your account.' + ) end end end - context 'without grace period defined' do - before(:each) do - stub_application_setting(two_factor_grace_period: 0) - login_with(user) + context 'group setting' do + before do + group1 = create :group, name: 'Group 1', require_two_factor_authentication: true + group1.add_user(user, GroupMember::DEVELOPER) + group2 = create :group, name: 'Group 2', require_two_factor_authentication: true + group2.add_user(user, GroupMember::DEVELOPER) end - it 'redirects to two-factor configuration page' do - expect(current_path).to eq profile_two_factor_auth_path - expect(page).to have_content('You must enable Two-Factor Authentication for your account.') + context 'with grace period defined' do + before(:each) do + stub_application_setting(two_factor_grace_period: 48) + login_with(user) + end + + context 'within the grace period' do + it 'redirects to two-factor configuration page' do + expect(current_path).to eq profile_two_factor_auth_path + expect(page).to have_content( + 'The group settings for Group 1 and Group 2 require you to enable ' \ + 'Two-Factor Authentication for your account. You need to do this ' \ + 'before ') + end + + it 'allows skipping two-factor configuration', js: true do + expect(current_path).to eq profile_two_factor_auth_path + + click_link 'Configure it later' + expect(current_path).to eq root_path + end + end + + context 'after the grace period' do + let(:user) { create(:user, otp_grace_period_started_at: 9999.hours.ago) } + + it 'redirects to two-factor configuration page' do + expect(current_path).to eq profile_two_factor_auth_path + expect(page).to have_content( + 'The group settings for Group 1 and Group 2 require you to enable ' \ + 'Two-Factor Authentication for your account.' + ) + end + + it 'disallows skipping two-factor configuration', js: true do + expect(current_path).to eq profile_two_factor_auth_path + expect(page).not_to have_link('Configure it later') + end + end + end + + context 'without grace period defined' do + before(:each) do + stub_application_setting(two_factor_grace_period: 0) + login_with(user) + end + + it 'redirects to two-factor configuration page' do + expect(current_path).to eq profile_two_factor_auth_path + expect(page).to have_content( + 'The group settings for Group 1 and Group 2 require you to enable ' \ + 'Two-Factor Authentication for your account.' + ) + end end end end diff --git a/spec/features/merge_requests/diff_notes_avatars_spec.rb b/spec/features/merge_requests/diff_notes_avatars_spec.rb index a6c72b0b3ac..218d95a88b8 100644 --- a/spec/features/merge_requests/diff_notes_avatars_spec.rb +++ b/spec/features/merge_requests/diff_notes_avatars_spec.rb @@ -164,9 +164,7 @@ feature 'Diff note avatars', feature: true, js: true do context 'multiple comments' do before do - create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: position) - create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: position) - create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: position) + create_list(:diff_note_on_merge_request, 3, project: project, noteable: merge_request, in_reply_to: note) visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request, view: view) diff --git a/spec/features/merge_requests/diff_notes_resolve_spec.rb b/spec/features/merge_requests/diff_notes_resolve_spec.rb index 69164aabdb2..88d28b649a4 100644 --- a/spec/features/merge_requests/diff_notes_resolve_spec.rb +++ b/spec/features/merge_requests/diff_notes_resolve_spec.rb @@ -191,7 +191,7 @@ feature 'Diff notes resolve', feature: true, js: true do context 'multiple notes' do before do - create(:diff_note_on_merge_request, project: project, noteable: merge_request) + create(:diff_note_on_merge_request, project: project, noteable: merge_request, in_reply_to: note) visit_merge_request end diff --git a/spec/features/merge_requests/reset_filters_spec.rb b/spec/features/merge_requests/reset_filters_spec.rb index 14511707af4..df5943f9136 100644 --- a/spec/features/merge_requests/reset_filters_spec.rb +++ b/spec/features/merge_requests/reset_filters_spec.rb @@ -14,7 +14,7 @@ feature 'Merge requests filter clear button', feature: true, js: true do let!(:mr2) { create(:merge_request, title: "Bugfix1", source_project: project, target_project: project, source_branch: "Bugfix1") } let(:merge_request_css) { '.merge-request' } - let(:clear_search_css) { '.filtered-search-input-container .clear-search' } + let(:clear_search_css) { '.filtered-search-box .clear-search' } before do mr2.labels << bug diff --git a/spec/features/notes_on_merge_requests_spec.rb b/spec/features/notes_on_merge_requests_spec.rb index fab2d532e06..783f2e93909 100644 --- a/spec/features/notes_on_merge_requests_spec.rb +++ b/spec/features/notes_on_merge_requests_spec.rb @@ -25,7 +25,7 @@ describe 'Comments', feature: true do describe 'the note form' do it 'is valid' do is_expected.to have_css('.js-main-target-form', visible: true, count: 1) - expect(find('.js-main-target-form input[type=submit]').value). + expect(find('.js-main-target-form .js-comment-button').value). to eq('Comment') page.within('.js-main-target-form') do expect(page).not_to have_link('Cancel') diff --git a/spec/features/projects/blobs/blob_show_spec.rb b/spec/features/projects/blobs/blob_show_spec.rb new file mode 100644 index 00000000000..01cd268ffe8 --- /dev/null +++ b/spec/features/projects/blobs/blob_show_spec.rb @@ -0,0 +1,23 @@ +require 'spec_helper' + +feature 'File blob', feature: true do + include WaitForAjax + include TreeHelper + + let(:project) { create(:project, :public, :test_repo) } + let(:merge_request) { create(:merge_request, source_project: project, source_branch: 'feature', target_branch: 'master') } + let(:branch) { 'master' } + let(:file_path) { project.repository.ls_files(project.repository.root_ref)[1] } + + context 'anonymous' do + context 'from blob file path' do + before do + visit namespace_project_blob_path(project.namespace, project, tree_join(branch, file_path)) + end + + it 'updates content' do + expect(page).to have_link 'Edit' + end + end + end +end diff --git a/spec/features/projects/blobs/edit_spec.rb b/spec/features/projects/blobs/edit_spec.rb index a820d07ab3b..46f2f487e0c 100644 --- a/spec/features/projects/blobs/edit_spec.rb +++ b/spec/features/projects/blobs/edit_spec.rb @@ -2,44 +2,135 @@ require 'spec_helper' feature 'Editing file blob', feature: true, js: true do include WaitForAjax + include TreeHelper - given(:user) { create(:user) } - given(:role) { :developer } - given(:merge_request) { create(:merge_request, source_branch: 'feature', target_branch: 'master') } - given(:project) { merge_request.target_project } + let(:project) { create(:project, :public, :test_repo) } + let(:merge_request) { create(:merge_request, source_project: project, source_branch: 'feature', target_branch: 'master') } + let(:branch) { 'master' } + let(:file_path) { project.repository.ls_files(project.repository.root_ref)[1] } - background do - login_as(user) - project.team << [user, role] - end - - def edit_and_commit - wait_for_ajax - first('.file-actions').click_link 'Edit' - execute_script('ace.edit("editor").setValue("class NextFeature\nend\n")') - click_button 'Commit Changes' - end + context 'as a developer' do + let(:user) { create(:user) } + let(:role) { :developer } - context 'from MR diff' do before do - visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request) - edit_and_commit + project.team << [user, role] + login_as(user) + end + + def edit_and_commit + wait_for_ajax + find('.js-edit-blob').click + execute_script('ace.edit("editor").setValue("class NextFeature\nend\n")') + click_button 'Commit Changes' + end + + context 'from MR diff' do + before do + visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request) + edit_and_commit + end + + it 'returns me to the mr' do + expect(page).to have_content(merge_request.title) + end end - scenario 'returns me to the mr' do - expect(page).to have_content(merge_request.title) + context 'from blob file path' do + before do + visit namespace_project_blob_path(project.namespace, project, tree_join(branch, file_path)) + edit_and_commit + end + + it 'updates content' do + expect(page).to have_content 'successfully committed' + expect(page).to have_content 'NextFeature' + end end end - context 'from blob file path' do - before do - visit namespace_project_blob_path(project.namespace, project, '/feature/files/ruby/feature.rb') - edit_and_commit + context 'visit blob edit' do + context 'redirects to sign in and returns' do + context 'as developer' do + let(:user) { create(:user) } + + before do + project.team << [user, :developer] + visit namespace_project_edit_blob_path(project.namespace, project, tree_join(branch, file_path)) + end + + it 'redirects to sign in and returns' do + expect(page).to have_current_path(new_user_session_path) + + login_as(user) + + expect(page).to have_current_path(namespace_project_edit_blob_path(project.namespace, project, tree_join(branch, file_path))) + end + end + + context 'as guest' do + let(:user) { create(:user) } + + before do + visit namespace_project_edit_blob_path(project.namespace, project, tree_join(branch, file_path)) + end + + it 'redirects to sign in and returns' do + expect(page).to have_current_path(new_user_session_path) + + login_as(user) + + expect(page).to have_current_path(namespace_project_blob_path(project.namespace, project, tree_join(branch, file_path))) + end + end + end + + context 'as developer' do + let(:user) { create(:user) } + let(:protected_branch) { 'protected-branch' } + + before do + project.team << [user, :developer] + project.repository.add_branch(user, protected_branch, 'master') + create(:protected_branch, project: project, name: protected_branch) + login_as(user) + end + + context 'on some branch' do + before do + visit namespace_project_edit_blob_path(project.namespace, project, tree_join(branch, file_path)) + end + + it 'shows blob editor with same branch' do + expect(page).to have_current_path(namespace_project_edit_blob_path(project.namespace, project, tree_join(branch, file_path))) + expect(find('.js-target-branch .dropdown-toggle-text').text).to eq(branch) + end + end + + context 'with protected branch' do + before do + visit namespace_project_edit_blob_path(project.namespace, project, tree_join(protected_branch, file_path)) + end + + it 'shows blob editor with patch branch' do + expect(find('.js-target-branch .dropdown-toggle-text').text).to eq('patch-1') + end + end end - scenario 'updates content' do - expect(page).to have_content 'successfully committed' - expect(page).to have_content 'NextFeature' + context 'as master' do + let(:user) { create(:user) } + + before do + project.team << [user, :master] + login_as(user) + visit namespace_project_edit_blob_path(project.namespace, project, tree_join(branch, file_path)) + end + + it 'shows blob editor with same branch' do + expect(page).to have_current_path(namespace_project_edit_blob_path(project.namespace, project, tree_join(branch, file_path))) + expect(find('.js-target-branch .dropdown-toggle-text').text).to eq(branch) + end end end end diff --git a/spec/features/projects/builds_spec.rb b/spec/features/projects/builds_spec.rb index 2116721b224..ab10434e10c 100644 --- a/spec/features/projects/builds_spec.rb +++ b/spec/features/projects/builds_spec.rb @@ -205,21 +205,13 @@ feature 'Builds', :feature do it 'loads job trace' do expect(page).to have_content 'BUILD TRACE' - build.append_trace(' and more trace', 11) + build.trace.write do |stream| + stream.append(' and more trace', 11) + end expect(page).to have_content 'BUILD TRACE and more trace' end end - - context 'when build does not have an initial trace' do - let(:build) { create(:ci_build, pipeline: pipeline) } - - it 'loads new trace' do - build.append_trace('build trace', 0) - - expect(page).to have_content 'build trace' - end - end end feature 'Variables' do @@ -390,7 +382,7 @@ feature 'Builds', :feature do it 'sends the right headers' do expect(page.status_code).to eq(200) expect(page.response_headers['Content-Type']).to eq('text/plain; charset=utf-8') - expect(page.response_headers['X-Sendfile']).to eq(build.path_to_trace) + expect(page.response_headers['X-Sendfile']).to eq(build.trace.send(:current_path)) end end @@ -409,43 +401,24 @@ feature 'Builds', :feature do context 'storage form' do let(:existing_file) { Tempfile.new('existing-trace-file').path } - let(:non_existing_file) do - file = Tempfile.new('non-existing-trace-file') - path = file.path - file.unlink - path - end - context 'when build has trace in file' do - before do - Capybara.current_session.driver.header('X-Sendfile-Type', 'X-Sendfile') - build.run! - visit namespace_project_build_path(project.namespace, project, build) + before do + Capybara.current_session.driver.header('X-Sendfile-Type', 'X-Sendfile') - allow_any_instance_of(Project).to receive(:ci_id).and_return(nil) - allow_any_instance_of(Ci::Build).to receive(:path_to_trace).and_return(existing_file) - allow_any_instance_of(Ci::Build).to receive(:old_path_to_trace).and_return(non_existing_file) + build.run! - page.within('.js-build-sidebar') { click_link 'Raw' } - end + allow_any_instance_of(Gitlab::Ci::Trace).to receive(:paths) + .and_return(paths) - it 'sends the right headers' do - expect(page.status_code).to eq(200) - expect(page.response_headers['Content-Type']).to eq('text/plain; charset=utf-8') - expect(page.response_headers['X-Sendfile']).to eq(existing_file) - end + visit namespace_project_build_path(project.namespace, project, build) end - context 'when build has trace in old file' do - before do - Capybara.current_session.driver.header('X-Sendfile-Type', 'X-Sendfile') - build.run! - visit namespace_project_build_path(project.namespace, project, build) - - allow_any_instance_of(Project).to receive(:ci_id).and_return(999) - allow_any_instance_of(Ci::Build).to receive(:path_to_trace).and_return(non_existing_file) - allow_any_instance_of(Ci::Build).to receive(:old_path_to_trace).and_return(existing_file) + context 'when build has trace in file' do + let(:paths) do + [existing_file] + end + before do page.within('.js-build-sidebar') { click_link 'Raw' } end @@ -457,20 +430,10 @@ feature 'Builds', :feature do end context 'when build has trace in DB' do - before do - Capybara.current_session.driver.header('X-Sendfile-Type', 'X-Sendfile') - build.run! - visit namespace_project_build_path(project.namespace, project, build) - - allow_any_instance_of(Project).to receive(:ci_id).and_return(nil) - allow_any_instance_of(Ci::Build).to receive(:path_to_trace).and_return(non_existing_file) - allow_any_instance_of(Ci::Build).to receive(:old_path_to_trace).and_return(non_existing_file) - - page.within('.js-build-sidebar') { click_link 'Raw' } - end + let(:paths) { [] } it 'sends the right headers' do - expect(page.status_code).to eq(404) + expect(page.status_code).not_to have_link('Raw') end end end diff --git a/spec/features/projects/new_project_spec.rb b/spec/features/projects/new_project_spec.rb index 52196ce49bd..c66b9a34b86 100644 --- a/spec/features/projects/new_project_spec.rb +++ b/spec/features/projects/new_project_spec.rb @@ -71,6 +71,22 @@ feature "New project", feature: true do end end end + + context "with subgroup namespace" do + let(:group) { create(:group, :private, owner: user) } + let(:subgroup) { create(:group, parent: group) } + + before do + group.add_master(user) + visit new_project_path(namespace_id: subgroup.id) + end + + it "selects the group namespace" do + namespace = find("#project_namespace_id option[selected]") + + expect(namespace.text).to eq subgroup.full_path + end + end end context 'Import project options' do diff --git a/spec/features/security/project/internal_access_spec.rb b/spec/features/security/project/internal_access_spec.rb index 1a66d1a6a1e..6ecdc8cbb71 100644 --- a/spec/features/security/project/internal_access_spec.rb +++ b/spec/features/security/project/internal_access_spec.rb @@ -443,9 +443,12 @@ describe "Internal Project Access", feature: true do end describe "GET /:project_path/container_registry" do + let(:container_repository) { create(:container_repository) } + before do - stub_container_registry_tags('latest') + stub_container_registry_tags(repository: :any, tags: ['latest']) stub_container_registry_config(enabled: true) + project.container_repositories << container_repository end subject { namespace_project_container_registry_index_path(project.namespace, project) } diff --git a/spec/features/security/project/private_access_spec.rb b/spec/features/security/project/private_access_spec.rb index ad3bd60a313..a8fc0624588 100644 --- a/spec/features/security/project/private_access_spec.rb +++ b/spec/features/security/project/private_access_spec.rb @@ -432,9 +432,12 @@ describe "Private Project Access", feature: true do end describe "GET /:project_path/container_registry" do + let(:container_repository) { create(:container_repository) } + before do - stub_container_registry_tags('latest') + stub_container_registry_tags(repository: :any, tags: ['latest']) stub_container_registry_config(enabled: true) + project.container_repositories << container_repository end subject { namespace_project_container_registry_index_path(project.namespace, project) } diff --git a/spec/features/security/project/public_access_spec.rb b/spec/features/security/project/public_access_spec.rb index e06aab4e0b2..c4d2f50ca14 100644 --- a/spec/features/security/project/public_access_spec.rb +++ b/spec/features/security/project/public_access_spec.rb @@ -443,9 +443,12 @@ describe "Public Project Access", feature: true do end describe "GET /:project_path/container_registry" do + let(:container_repository) { create(:container_repository) } + before do - stub_container_registry_tags('latest') + stub_container_registry_tags(repository: :any, tags: ['latest']) stub_container_registry_config(enabled: true) + project.container_repositories << container_repository end subject { namespace_project_container_registry_index_path(project.namespace, project) } diff --git a/spec/finders/notes_finder_spec.rb b/spec/finders/notes_finder_spec.rb index 77a04507be1..765bf44d863 100644 --- a/spec/finders/notes_finder_spec.rb +++ b/spec/finders/notes_finder_spec.rb @@ -202,4 +202,45 @@ describe NotesFinder do end end end + + describe '#target' do + subject { described_class.new(project, user, params) } + + context 'for a issue target' do + let(:issue) { create(:issue, project: project) } + let(:params) { { target_type: 'issue', target_id: issue.id } } + + it 'returns the issue' do + expect(subject.target).to eq(issue) + end + end + + context 'for a merge request target' do + let(:merge_request) { create(:merge_request, source_project: project) } + let(:params) { { target_type: 'merge_request', target_id: merge_request.id } } + + it 'returns the merge_request' do + expect(subject.target).to eq(merge_request) + end + end + + context 'for a snippet target' do + let(:snippet) { create(:project_snippet, project: project) } + let(:params) { { target_type: 'snippet', target_id: snippet.id } } + + it 'returns the snippet' do + expect(subject.target).to eq(snippet) + end + end + + context 'for a commit target' do + let(:project) { create(:project, :repository) } + let(:commit) { project.commit } + let(:params) { { target_type: 'commit', target_id: commit.id } } + + it 'returns the commit' do + expect(subject.target).to eq(commit) + end + end + end end diff --git a/spec/helpers/blob_helper_spec.rb b/spec/helpers/blob_helper_spec.rb index bead7948486..508aeb7cf67 100644 --- a/spec/helpers/blob_helper_spec.rb +++ b/spec/helpers/blob_helper_spec.rb @@ -73,7 +73,7 @@ describe BlobHelper do let(:project) { create(:project, :repository, namespace: namespace) } before do - allow(self).to receive(:current_user).and_return(double) + allow(self).to receive(:current_user).and_return(nil) allow(self).to receive(:can_collaborate_with_project?).and_return(true) end diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb index f0554cc068d..540cb0ab1e0 100644 --- a/spec/helpers/issues_helper_spec.rb +++ b/spec/helpers/issues_helper_spec.rb @@ -150,7 +150,7 @@ describe IssuesHelper do describe "when passing a discussion" do let(:diff_note) { create(:diff_note_on_merge_request) } let(:merge_request) { diff_note.noteable } - let(:discussion) { Discussion.new([diff_note]) } + let(:discussion) { diff_note.to_discussion } it "links to the merge request with first note if a single discussion was passed" do expected_path = Gitlab::UrlBuilder.build(diff_note) diff --git a/spec/helpers/notes_helper_spec.rb b/spec/helpers/notes_helper_spec.rb index 9c577501f00..a427de32c4c 100644 --- a/spec/helpers/notes_helper_spec.rb +++ b/spec/helpers/notes_helper_spec.rb @@ -36,21 +36,4 @@ describe NotesHelper do expect(helper.note_max_access_for_user(other_note)).to eq('Reporter') end end - - describe '#preload_max_access_for_authors' do - before do - # This method reads cache from RequestStore, so make sure it's clean. - RequestStore.clear! - end - - it 'loads multiple users' do - expected_access = { - owner.id => Gitlab::Access::OWNER, - master.id => Gitlab::Access::MASTER, - reporter.id => Gitlab::Access::REPORTER - } - - expect(helper.preload_max_access_for_authors(notes, project)).to eq(expected_access) - end - end end diff --git a/spec/javascripts/build_spec.js b/spec/javascripts/build_spec.js index beee6cb2969..cb48f14fd9a 100644 --- a/spec/javascripts/build_spec.js +++ b/spec/javascripts/build_spec.js @@ -72,12 +72,14 @@ describe('Build', () => { it('displays the initial build trace', () => { expect($.ajax.calls.count()).toBe(1); const [{ url, dataType, success, context }] = $.ajax.calls.argsFor(0); - expect(url).toBe(`${BUILD_URL}.json`); + expect(url).toBe( + `${BUILD_URL}/trace.json`, + ); expect(dataType).toBe('json'); expect(success).toEqual(jasmine.any(Function)); spyOn(gl.utils, 'setCiStatusFavicon').and.callFake(() => {}); - success.call(context, { trace_html: '<span>Example</span>', status: 'running' }); + success.call(context, { html: '<span>Example</span>', status: 'running' }); expect($('#build-trace .js-build-output').text()).toMatch(/Example/); }); diff --git a/spec/javascripts/comment_type_toggle_spec.js b/spec/javascripts/comment_type_toggle_spec.js new file mode 100644 index 00000000000..818c1635710 --- /dev/null +++ b/spec/javascripts/comment_type_toggle_spec.js @@ -0,0 +1,124 @@ +import CommentTypeToggle from '~/comment_type_toggle'; +import * as dropLabSrc from '~/droplab/drop_lab'; +import InputSetter from '~/droplab/plugins/input_setter'; + +describe('CommentTypeToggle', function () { + describe('class constructor', function () { + beforeEach(function () { + this.dropdownTrigger = {}; + this.dropdownList = {}; + this.noteTypeInput = {}; + this.submitButton = {}; + this.closeButton = {}; + + this.commentTypeToggle = new CommentTypeToggle( + this.dropdownTrigger, + this.dropdownList, + this.noteTypeInput, + this.submitButton, + this.closeButton, + ); + }); + + it('should set .dropdownTrigger', function () { + expect(this.commentTypeToggle.dropdownTrigger).toBe(this.dropdownTrigger); + }); + + it('should set .dropdownList', function () { + expect(this.commentTypeToggle.dropdownList).toBe(this.dropdownList); + }); + + it('should set .noteTypeInput', function () { + expect(this.commentTypeToggle.noteTypeInput).toBe(this.noteTypeInput); + }); + + it('should set .submitButton', function () { + expect(this.commentTypeToggle.submitButton).toBe(this.submitButton); + }); + + it('should set .closeButton', function () { + expect(this.commentTypeToggle.closeButton).toBe(this.closeButton); + }); + }); + + describe('initDroplab', function () { + beforeEach(function () { + this.commentTypeToggle = { + dropdownTrigger: {}, + dropdownList: {}, + noteTypeInput: {}, + submitButton: {}, + closeButton: {}, + }; + + this.droplab = jasmine.createSpyObj('droplab', ['init']); + + spyOn(dropLabSrc, 'default').and.returnValue(this.droplab); + + this.initDroplab = CommentTypeToggle.prototype.initDroplab.call(this.commentTypeToggle); + }); + + it('should instantiate a DropLab instance', function () { + expect(dropLabSrc.default).toHaveBeenCalled(); + }); + + it('should set .droplab', function () { + expect(this.commentTypeToggle.droplab).toBe(this.droplab); + }); + + it('should call DropLab.prototype.init', function () { + expect(this.droplab.init).toHaveBeenCalledWith( + this.commentTypeToggle.dropdownTrigger, + this.commentTypeToggle.dropdownList, + [InputSetter], + { + InputSetter: [{ + input: this.commentTypeToggle.noteTypeInput, + valueAttribute: 'data-value', + }, { + input: this.commentTypeToggle.submitButton, + valueAttribute: 'data-button-text', + }, + { + input: this.commentTypeToggle.closeButton, + valueAttribute: 'data-secondary-button-text', + }, { + input: this.commentTypeToggle.closeButton, + valueAttribute: 'data-secondary-button-text', + inputAttribute: 'data-alternative-text', + }], + }, + ); + }); + + describe('if no .closeButton is provided', function () { + beforeEach(function () { + this.commentTypeToggle = { + dropdownTrigger: {}, + dropdownList: {}, + noteTypeInput: {}, + submitButton: {}, + }; + + this.initDroplab = CommentTypeToggle.prototype.initDroplab.call(this.commentTypeToggle); + }); + + it('should not add .closeButton related InputSetter config', function () { + expect(this.droplab.init).toHaveBeenCalledWith( + this.commentTypeToggle.dropdownTrigger, + this.commentTypeToggle.dropdownList, + [InputSetter], + { + InputSetter: [{ + input: this.commentTypeToggle.noteTypeInput, + valueAttribute: 'data-value', + }, { + input: this.commentTypeToggle.submitButton, + valueAttribute: 'data-button-text', + }], + }, + ); + }); + }); + }); +}); diff --git a/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js b/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js new file mode 100644 index 00000000000..2722882375f --- /dev/null +++ b/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js @@ -0,0 +1,166 @@ +import Vue from 'vue'; +import eventHub from '~/filtered_search/event_hub'; +import RecentSearchesDropdownContent from '~/filtered_search/components/recent_searches_dropdown_content'; + +const createComponent = (propsData) => { + const Component = Vue.extend(RecentSearchesDropdownContent); + + return new Component({ + el: document.createElement('div'), + propsData, + }); +}; + +// Remove all the newlines and whitespace from the formatted markup +const trimMarkupWhitespace = text => text.replace(/(\n|\s)+/gm, ' ').trim(); + +describe('RecentSearchesDropdownContent', () => { + const propsDataWithoutItems = { + items: [], + }; + const propsDataWithItems = { + items: [ + 'foo', + 'author:@root label:~foo bar', + ], + }; + + let vm; + afterEach(() => { + if (vm) { + vm.$destroy(); + } + }); + + describe('with no items', () => { + let el; + + beforeEach(() => { + vm = createComponent(propsDataWithoutItems); + el = vm.$el; + }); + + it('should render empty state', () => { + expect(el.querySelector('.dropdown-info-note')).toBeDefined(); + + const items = el.querySelectorAll('.filtered-search-history-dropdown-item'); + expect(items.length).toEqual(propsDataWithoutItems.items.length); + }); + }); + + describe('with items', () => { + let el; + + beforeEach(() => { + vm = createComponent(propsDataWithItems); + el = vm.$el; + }); + + it('should render clear recent searches button', () => { + expect(el.querySelector('.filtered-search-history-clear-button')).toBeDefined(); + }); + + it('should render recent search items', () => { + const items = el.querySelectorAll('.filtered-search-history-dropdown-item'); + expect(items.length).toEqual(propsDataWithItems.items.length); + + expect(trimMarkupWhitespace(items[0].querySelector('.filtered-search-history-dropdown-search-token').textContent)).toEqual('foo'); + + const item1Tokens = items[1].querySelectorAll('.filtered-search-history-dropdown-token'); + expect(item1Tokens.length).toEqual(2); + expect(item1Tokens[0].querySelector('.name').textContent).toEqual('author:'); + expect(item1Tokens[0].querySelector('.value').textContent).toEqual('@root'); + expect(item1Tokens[1].querySelector('.name').textContent).toEqual('label:'); + expect(item1Tokens[1].querySelector('.value').textContent).toEqual('~foo'); + expect(trimMarkupWhitespace(items[1].querySelector('.filtered-search-history-dropdown-search-token').textContent)).toEqual('bar'); + }); + }); + + describe('computed', () => { + describe('processedItems', () => { + it('with items', () => { + vm = createComponent(propsDataWithItems); + const processedItems = vm.processedItems; + + expect(processedItems.length).toEqual(2); + + expect(processedItems[0].text).toEqual(propsDataWithItems.items[0]); + expect(processedItems[0].tokens).toEqual([]); + expect(processedItems[0].searchToken).toEqual('foo'); + + expect(processedItems[1].text).toEqual(propsDataWithItems.items[1]); + expect(processedItems[1].tokens.length).toEqual(2); + expect(processedItems[1].tokens[0].prefix).toEqual('author:'); + expect(processedItems[1].tokens[0].suffix).toEqual('@root'); + expect(processedItems[1].tokens[1].prefix).toEqual('label:'); + expect(processedItems[1].tokens[1].suffix).toEqual('~foo'); + expect(processedItems[1].searchToken).toEqual('bar'); + }); + + it('with no items', () => { + vm = createComponent(propsDataWithoutItems); + const processedItems = vm.processedItems; + + expect(processedItems.length).toEqual(0); + }); + }); + + describe('hasItems', () => { + it('with items', () => { + vm = createComponent(propsDataWithItems); + const hasItems = vm.hasItems; + expect(hasItems).toEqual(true); + }); + + it('with no items', () => { + vm = createComponent(propsDataWithoutItems); + const hasItems = vm.hasItems; + expect(hasItems).toEqual(false); + }); + }); + }); + + describe('methods', () => { + describe('onItemActivated', () => { + let onRecentSearchesItemSelectedSpy; + + beforeEach(() => { + onRecentSearchesItemSelectedSpy = jasmine.createSpy('spy'); + eventHub.$on('recentSearchesItemSelected', onRecentSearchesItemSelectedSpy); + + vm = createComponent(propsDataWithItems); + }); + + afterEach(() => { + eventHub.$off('recentSearchesItemSelected', onRecentSearchesItemSelectedSpy); + }); + + it('emits event', () => { + expect(onRecentSearchesItemSelectedSpy).not.toHaveBeenCalled(); + vm.onItemActivated('something'); + expect(onRecentSearchesItemSelectedSpy).toHaveBeenCalledWith('something'); + }); + }); + + describe('onRequestClearRecentSearches', () => { + let onRequestClearRecentSearchesSpy; + + beforeEach(() => { + onRequestClearRecentSearchesSpy = jasmine.createSpy('spy'); + eventHub.$on('requestClearRecentSearches', onRequestClearRecentSearchesSpy); + + vm = createComponent(propsDataWithItems); + }); + + afterEach(() => { + eventHub.$off('requestClearRecentSearches', onRequestClearRecentSearchesSpy); + }); + + it('emits event', () => { + expect(onRequestClearRecentSearchesSpy).not.toHaveBeenCalled(); + vm.onRequestClearRecentSearches({ stopPropagation: () => {} }); + expect(onRequestClearRecentSearchesSpy).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/spec/javascripts/filtered_search/filtered_search_manager_spec.js b/spec/javascripts/filtered_search/filtered_search_manager_spec.js index 5f7c05e9014..97af681429b 100644 --- a/spec/javascripts/filtered_search/filtered_search_manager_spec.js +++ b/spec/javascripts/filtered_search/filtered_search_manager_spec.js @@ -29,7 +29,7 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper beforeEach(() => { setFixtures(` - <div class="filtered-search-input-container"> + <div class="filtered-search-box"> <form> <ul class="tokens-container list-unstyled"> ${FilteredSearchSpecHelper.createInputHTML(placeholder)} @@ -264,12 +264,12 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper describe('toggleInputContainerFocus', () => { it('toggles on focus', () => { input.focus(); - expect(document.querySelector('.filtered-search-input-container').classList.contains('focus')).toEqual(true); + expect(document.querySelector('.filtered-search-box').classList.contains('focus')).toEqual(true); }); it('toggles on blur', () => { input.blur(); - expect(document.querySelector('.filtered-search-input-container').classList.contains('focus')).toEqual(false); + expect(document.querySelector('.filtered-search-box').classList.contains('focus')).toEqual(false); }); }); }); diff --git a/spec/javascripts/filtered_search/services/recent_searches_service_spec.js b/spec/javascripts/filtered_search/services/recent_searches_service_spec.js new file mode 100644 index 00000000000..2a58fb3a7df --- /dev/null +++ b/spec/javascripts/filtered_search/services/recent_searches_service_spec.js @@ -0,0 +1,56 @@ +import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; + +describe('RecentSearchesService', () => { + let service; + + beforeEach(() => { + service = new RecentSearchesService(); + window.localStorage.removeItem(service.localStorageKey); + }); + + describe('fetch', () => { + it('should default to empty array', (done) => { + const fetchItemsPromise = service.fetch(); + + fetchItemsPromise + .then((items) => { + expect(items).toEqual([]); + done(); + }) + .catch((err) => { + done.fail('Shouldn\'t reject with empty localStorage key', err); + }); + }); + + it('should reject when unable to parse', (done) => { + window.localStorage.setItem(service.localStorageKey, 'fail'); + const fetchItemsPromise = service.fetch(); + + fetchItemsPromise + .catch(() => { + done(); + }); + }); + + it('should return items from localStorage', (done) => { + window.localStorage.setItem(service.localStorageKey, '["foo", "bar"]'); + const fetchItemsPromise = service.fetch(); + + fetchItemsPromise + .then((items) => { + expect(items).toEqual(['foo', 'bar']); + done(); + }); + }); + }); + + describe('setRecentSearches', () => { + it('should save things in localStorage', () => { + const items = ['foo', 'bar']; + service.save(items); + const newLocalStorageValue = + window.localStorage.getItem(service.localStorageKey); + expect(JSON.parse(newLocalStorageValue)).toEqual(items); + }); + }); +}); diff --git a/spec/javascripts/filtered_search/stores/recent_searches_store_spec.js b/spec/javascripts/filtered_search/stores/recent_searches_store_spec.js new file mode 100644 index 00000000000..1eebc6f2367 --- /dev/null +++ b/spec/javascripts/filtered_search/stores/recent_searches_store_spec.js @@ -0,0 +1,59 @@ +import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store'; + +describe('RecentSearchesStore', () => { + let store; + + beforeEach(() => { + store = new RecentSearchesStore(); + }); + + describe('addRecentSearch', () => { + it('should add to the front of the list', () => { + store.addRecentSearch('foo'); + store.addRecentSearch('bar'); + + expect(store.state.recentSearches).toEqual(['bar', 'foo']); + }); + + it('should deduplicate', () => { + store.addRecentSearch('foo'); + store.addRecentSearch('bar'); + store.addRecentSearch('foo'); + + expect(store.state.recentSearches).toEqual(['foo', 'bar']); + }); + + it('only keeps track of 5 items', () => { + store.addRecentSearch('1'); + store.addRecentSearch('2'); + store.addRecentSearch('3'); + store.addRecentSearch('4'); + store.addRecentSearch('5'); + store.addRecentSearch('6'); + store.addRecentSearch('7'); + + expect(store.state.recentSearches).toEqual(['7', '6', '5', '4', '3']); + }); + }); + + describe('setRecentSearches', () => { + it('should override list', () => { + store.setRecentSearches([ + 'foo', + 'bar', + ]); + store.setRecentSearches([ + 'baz', + 'qux', + ]); + + expect(store.state.recentSearches).toEqual(['baz', 'qux']); + }); + + it('only keeps track of 5 items', () => { + store.setRecentSearches(['1', '2', '3', '4', '5', '6', '7']); + + expect(store.state.recentSearches).toEqual(['1', '2', '3', '4', '5']); + }); + }); +}); diff --git a/spec/javascripts/fixtures/environments/metrics.html.haml b/spec/javascripts/fixtures/environments/metrics.html.haml index 483063fb889..e2dd9519898 100644 --- a/spec/javascripts/fixtures/environments/metrics.html.haml +++ b/spec/javascripts/fixtures/environments/metrics.html.haml @@ -1,12 +1,62 @@ -%div +.prometheus-container{ 'data-has-metrics': "false", 'data-doc-link': '/help/administration/monitoring/prometheus/index.md', 'data-prometheus-integration': '/root/hello-prometheus/services/prometheus/edit' } .top-area .row .col-sm-6 %h3.page-title Metrics for environment - .row - .col-sm-12 - %svg.prometheus-graph{ 'graph-type' => 'cpu_values' } - .row - .col-sm-12 - %svg.prometheus-graph{ 'graph-type' => 'memory_values' }
\ No newline at end of file + .prometheus-state + .js-getting-started.hidden + .row + .col-md-4.col-md-offset-4.state-svg + %svg + .row + .col-md-6.col-md-offset-3 + %h4.text-center.state-title + Get started with performance monitoring + .row + .col-md-6.col-md-offset-3 + .description-text.text-center.state-description + Stay updated about the performance and health of your environment by configuring Prometheus to monitor your deployments. Learn more about performance monitoring + .row.state-button-section + .col-md-4.col-md-offset-4.text-center.state-button + %a.btn.btn-success + Configure Prometheus + .js-loading.hidden + .row + .col-md-4.col-md-offset-4.state-svg + %svg + .row + .col-md-6.col-md-offset-3 + %h4.text-center.state-title + Waiting for performance data + .row + .col-md-6.col-md-offset-3 + .description-text.text-center.state-description + Creating graphs uses the data from the Prometheus server. If this takes a long time, ensure that data is available. + .row.state-button-section + .col-md-4.col-md-offset-4.text-center.state-button + %a.btn.btn-success + View documentation + .js-unable-to-connect.hidden + .row + .col-md-4.col-md-offset-4.state-svg + %svg + .row + .col-md-6.col-md-offset-3 + %h4.text-center.state-title + Unable to connect to Prometheus server + .row + .col-md-6.col-md-offset-3 + .description-text.text-center.state-description + Ensure connectivity is available from the GitLab server to the Prometheus server + .row.state-button-section + .col-md-4.col-md-offset-4.text-center.state-button + %a.btn.btn-success + View documentation + .prometheus-graphs + .row + .col-sm-12 + %svg.prometheus-graph{ 'graph-type' => 'cpu_values' } + .row + .col-sm-12 + %svg.prometheus-graph{ 'graph-type' => 'memory_values' } diff --git a/spec/javascripts/monitoring/prometheus_graph_spec.js b/spec/javascripts/monitoring/prometheus_graph_spec.js index c2bcd9c0f7c..4b904fc2960 100644 --- a/spec/javascripts/monitoring/prometheus_graph_spec.js +++ b/spec/javascripts/monitoring/prometheus_graph_spec.js @@ -1,5 +1,4 @@ import 'jquery'; -import '~/lib/utils/common_utils'; import PrometheusGraph from '~/monitoring/prometheus_graph'; import { prometheusMockData } from './prometheus_mock_data'; @@ -12,6 +11,7 @@ describe('PrometheusGraph', () => { beforeEach(() => { loadFixtures(fixtureName); + $('.prometheus-container').data('has-metrics', 'true'); this.prometheusGraph = new PrometheusGraph(); const self = this; const fakeInit = (metricsResponse) => { @@ -75,3 +75,24 @@ describe('PrometheusGraph', () => { }); }); }); + +describe('PrometheusGraphs UX states', () => { + const fixtureName = 'static/environments/metrics.html.raw'; + preloadFixtures(fixtureName); + + beforeEach(() => { + loadFixtures(fixtureName); + this.prometheusGraph = new PrometheusGraph(); + }); + + it('shows a specified state', () => { + this.prometheusGraph.state = '.js-getting-started'; + this.prometheusGraph.updateState(); + const $state = $('.js-getting-started'); + expect($state).toBeDefined(); + expect($('.state-title', $state)).toBeDefined(); + expect($('.state-svg', $state)).toBeDefined(); + expect($('.state-description', $state)).toBeDefined(); + expect($('.state-button', $state)).toBeDefined(); + }); +}); diff --git a/spec/lib/ci/ansi2html_spec.rb b/spec/lib/ci/ansi2html_spec.rb index 0762fd7e56a..a5dfb49478a 100644 --- a/spec/lib/ci/ansi2html_spec.rb +++ b/spec/lib/ci/ansi2html_spec.rb @@ -1,159 +1,160 @@ require 'spec_helper' describe Ci::Ansi2html, lib: true do - subject { Ci::Ansi2html } + subject { described_class } it "prints non-ansi as-is" do - expect(subject.convert("Hello")[:html]).to eq('Hello') + expect(convert_html("Hello")).to eq('Hello') end it "strips non-color-changing controll sequences" do - expect(subject.convert("Hello \e[2Kworld")[:html]).to eq('Hello world') + expect(convert_html("Hello \e[2Kworld")).to eq('Hello world') end it "prints simply red" do - expect(subject.convert("\e[31mHello\e[0m")[:html]).to eq('<span class="term-fg-red">Hello</span>') + expect(convert_html("\e[31mHello\e[0m")).to eq('<span class="term-fg-red">Hello</span>') end it "prints simply red without trailing reset" do - expect(subject.convert("\e[31mHello")[:html]).to eq('<span class="term-fg-red">Hello</span>') + expect(convert_html("\e[31mHello")).to eq('<span class="term-fg-red">Hello</span>') end it "prints simply yellow" do - expect(subject.convert("\e[33mHello\e[0m")[:html]).to eq('<span class="term-fg-yellow">Hello</span>') + expect(convert_html("\e[33mHello\e[0m")).to eq('<span class="term-fg-yellow">Hello</span>') end it "prints default on blue" do - expect(subject.convert("\e[39;44mHello")[:html]).to eq('<span class="term-bg-blue">Hello</span>') + expect(convert_html("\e[39;44mHello")).to eq('<span class="term-bg-blue">Hello</span>') end it "prints red on blue" do - expect(subject.convert("\e[31;44mHello")[:html]).to eq('<span class="term-fg-red term-bg-blue">Hello</span>') + expect(convert_html("\e[31;44mHello")).to eq('<span class="term-fg-red term-bg-blue">Hello</span>') end it "resets colors after red on blue" do - expect(subject.convert("\e[31;44mHello\e[0m world")[:html]).to eq('<span class="term-fg-red term-bg-blue">Hello</span> world') + expect(convert_html("\e[31;44mHello\e[0m world")).to eq('<span class="term-fg-red term-bg-blue">Hello</span> world') end it "performs color change from red/blue to yellow/blue" do - expect(subject.convert("\e[31;44mHello \e[33mworld")[:html]).to eq('<span class="term-fg-red term-bg-blue">Hello </span><span class="term-fg-yellow term-bg-blue">world</span>') + expect(convert_html("\e[31;44mHello \e[33mworld")).to eq('<span class="term-fg-red term-bg-blue">Hello </span><span class="term-fg-yellow term-bg-blue">world</span>') end it "performs color change from red/blue to yellow/green" do - expect(subject.convert("\e[31;44mHello \e[33;42mworld")[:html]).to eq('<span class="term-fg-red term-bg-blue">Hello </span><span class="term-fg-yellow term-bg-green">world</span>') + expect(convert_html("\e[31;44mHello \e[33;42mworld")).to eq('<span class="term-fg-red term-bg-blue">Hello </span><span class="term-fg-yellow term-bg-green">world</span>') end it "performs color change from red/blue to reset to yellow/green" do - expect(subject.convert("\e[31;44mHello\e[0m \e[33;42mworld")[:html]).to eq('<span class="term-fg-red term-bg-blue">Hello</span> <span class="term-fg-yellow term-bg-green">world</span>') + expect(convert_html("\e[31;44mHello\e[0m \e[33;42mworld")).to eq('<span class="term-fg-red term-bg-blue">Hello</span> <span class="term-fg-yellow term-bg-green">world</span>') end it "ignores unsupported codes" do - expect(subject.convert("\e[51mHello\e[0m")[:html]).to eq('Hello') + expect(convert_html("\e[51mHello\e[0m")).to eq('Hello') end it "prints light red" do - expect(subject.convert("\e[91mHello\e[0m")[:html]).to eq('<span class="term-fg-l-red">Hello</span>') + expect(convert_html("\e[91mHello\e[0m")).to eq('<span class="term-fg-l-red">Hello</span>') end it "prints default on light red" do - expect(subject.convert("\e[101mHello\e[0m")[:html]).to eq('<span class="term-bg-l-red">Hello</span>') + expect(convert_html("\e[101mHello\e[0m")).to eq('<span class="term-bg-l-red">Hello</span>') end it "performs color change from red/blue to default/blue" do - expect(subject.convert("\e[31;44mHello \e[39mworld")[:html]).to eq('<span class="term-fg-red term-bg-blue">Hello </span><span class="term-bg-blue">world</span>') + expect(convert_html("\e[31;44mHello \e[39mworld")).to eq('<span class="term-fg-red term-bg-blue">Hello </span><span class="term-bg-blue">world</span>') end it "performs color change from light red/blue to default/blue" do - expect(subject.convert("\e[91;44mHello \e[39mworld")[:html]).to eq('<span class="term-fg-l-red term-bg-blue">Hello </span><span class="term-bg-blue">world</span>') + expect(convert_html("\e[91;44mHello \e[39mworld")).to eq('<span class="term-fg-l-red term-bg-blue">Hello </span><span class="term-bg-blue">world</span>') end it "prints bold text" do - expect(subject.convert("\e[1mHello")[:html]).to eq('<span class="term-bold">Hello</span>') + expect(convert_html("\e[1mHello")).to eq('<span class="term-bold">Hello</span>') end it "resets bold text" do - expect(subject.convert("\e[1mHello\e[21m world")[:html]).to eq('<span class="term-bold">Hello</span> world') - expect(subject.convert("\e[1mHello\e[22m world")[:html]).to eq('<span class="term-bold">Hello</span> world') + expect(convert_html("\e[1mHello\e[21m world")).to eq('<span class="term-bold">Hello</span> world') + expect(convert_html("\e[1mHello\e[22m world")).to eq('<span class="term-bold">Hello</span> world') end it "prints italic text" do - expect(subject.convert("\e[3mHello")[:html]).to eq('<span class="term-italic">Hello</span>') + expect(convert_html("\e[3mHello")).to eq('<span class="term-italic">Hello</span>') end it "resets italic text" do - expect(subject.convert("\e[3mHello\e[23m world")[:html]).to eq('<span class="term-italic">Hello</span> world') + expect(convert_html("\e[3mHello\e[23m world")).to eq('<span class="term-italic">Hello</span> world') end it "prints underlined text" do - expect(subject.convert("\e[4mHello")[:html]).to eq('<span class="term-underline">Hello</span>') + expect(convert_html("\e[4mHello")).to eq('<span class="term-underline">Hello</span>') end it "resets underlined text" do - expect(subject.convert("\e[4mHello\e[24m world")[:html]).to eq('<span class="term-underline">Hello</span> world') + expect(convert_html("\e[4mHello\e[24m world")).to eq('<span class="term-underline">Hello</span> world') end it "prints concealed text" do - expect(subject.convert("\e[8mHello")[:html]).to eq('<span class="term-conceal">Hello</span>') + expect(convert_html("\e[8mHello")).to eq('<span class="term-conceal">Hello</span>') end it "resets concealed text" do - expect(subject.convert("\e[8mHello\e[28m world")[:html]).to eq('<span class="term-conceal">Hello</span> world') + expect(convert_html("\e[8mHello\e[28m world")).to eq('<span class="term-conceal">Hello</span> world') end it "prints crossed-out text" do - expect(subject.convert("\e[9mHello")[:html]).to eq('<span class="term-cross">Hello</span>') + expect(convert_html("\e[9mHello")).to eq('<span class="term-cross">Hello</span>') end it "resets crossed-out text" do - expect(subject.convert("\e[9mHello\e[29m world")[:html]).to eq('<span class="term-cross">Hello</span> world') + expect(convert_html("\e[9mHello\e[29m world")).to eq('<span class="term-cross">Hello</span> world') end it "can print 256 xterm fg colors" do - expect(subject.convert("\e[38;5;16mHello")[:html]).to eq('<span class="xterm-fg-16">Hello</span>') + expect(convert_html("\e[38;5;16mHello")).to eq('<span class="xterm-fg-16">Hello</span>') end it "can print 256 xterm fg colors on normal magenta background" do - expect(subject.convert("\e[38;5;16;45mHello")[:html]).to eq('<span class="xterm-fg-16 term-bg-magenta">Hello</span>') + expect(convert_html("\e[38;5;16;45mHello")).to eq('<span class="xterm-fg-16 term-bg-magenta">Hello</span>') end it "can print 256 xterm bg colors" do - expect(subject.convert("\e[48;5;240mHello")[:html]).to eq('<span class="xterm-bg-240">Hello</span>') + expect(convert_html("\e[48;5;240mHello")).to eq('<span class="xterm-bg-240">Hello</span>') end it "can print 256 xterm bg colors on normal magenta foreground" do - expect(subject.convert("\e[48;5;16;35mHello")[:html]).to eq('<span class="term-fg-magenta xterm-bg-16">Hello</span>') + expect(convert_html("\e[48;5;16;35mHello")).to eq('<span class="term-fg-magenta xterm-bg-16">Hello</span>') end it "prints bold colored text vividly" do - expect(subject.convert("\e[1;31mHello\e[0m")[:html]).to eq('<span class="term-fg-l-red term-bold">Hello</span>') + expect(convert_html("\e[1;31mHello\e[0m")).to eq('<span class="term-fg-l-red term-bold">Hello</span>') end it "prints bold light colored text correctly" do - expect(subject.convert("\e[1;91mHello\e[0m")[:html]).to eq('<span class="term-fg-l-red term-bold">Hello</span>') + expect(convert_html("\e[1;91mHello\e[0m")).to eq('<span class="term-fg-l-red term-bold">Hello</span>') end it "prints <" do - expect(subject.convert("<")[:html]).to eq('<') + expect(convert_html("<")).to eq('<') end it "replaces newlines with line break tags" do - expect(subject.convert("\n")[:html]).to eq('<br>') + expect(convert_html("\n")).to eq('<br>') end it "groups carriage returns with newlines" do - expect(subject.convert("\r\n")[:html]).to eq('<br>') + expect(convert_html("\r\n")).to eq('<br>') end describe "incremental update" do shared_examples 'stateable converter' do - let(:pass1) { subject.convert(pre_text) } - let(:pass2) { subject.convert(pre_text + text, pass1[:state]) } + let(:pass1_stream) { StringIO.new(pre_text) } + let(:pass2_stream) { StringIO.new(pre_text + text) } + let(:pass1) { subject.convert(pass1_stream) } + let(:pass2) { subject.convert(pass2_stream, pass1.state) } it "to returns html to append" do - expect(pass2[:append]).to be_truthy - expect(pass2[:html]).to eq(html) - expect(pass1[:text] + pass2[:text]).to eq(pre_text + text) - expect(pass1[:html] + pass2[:html]).to eq(pre_html + html) + expect(pass2.append).to be_truthy + expect(pass2.html).to eq(html) + expect(pass1.html + pass2.html).to eq(pre_html + html) end end @@ -193,4 +194,27 @@ describe Ci::Ansi2html, lib: true do it_behaves_like 'stateable converter' end end + + describe "truncates" do + let(:text) { "Hello World" } + let(:stream) { StringIO.new(text) } + let(:subject) { described_class.convert(stream) } + + before do + stream.seek(3, IO::SEEK_SET) + end + + it "returns truncated output" do + expect(subject.truncated).to be_truthy + end + + it "does not append output" do + expect(subject.append).to be_falsey + end + end + + def convert_html(data) + stream = StringIO.new(data) + subject.convert(stream).html + end end diff --git a/spec/lib/container_registry/blob_spec.rb b/spec/lib/container_registry/blob_spec.rb index bbacdc67ebd..f06e5fd54a2 100644 --- a/spec/lib/container_registry/blob_spec.rb +++ b/spec/lib/container_registry/blob_spec.rb @@ -1,110 +1,121 @@ require 'spec_helper' describe ContainerRegistry::Blob do - let(:digest) { 'sha256:0123456789012345' } + let(:group) { create(:group, name: 'group') } + let(:project) { create(:empty_project, path: 'test', group: group) } + + let(:repository) do + create(:container_repository, name: 'image', + tags: %w[latest rc1], + project: project) + end + let(:config) do - { - 'digest' => digest, + { 'digest' => 'sha256:0123456789012345', 'mediaType' => 'binary', - 'size' => 1000 - } + 'size' => 1000 } + end + + let(:blob) { described_class.new(repository, config) } + + before do + stub_container_registry_config(enabled: true, + api_url: 'http://registry.gitlab', + host_port: 'registry.gitlab') end - let(:token) { 'authorization-token' } - - let(:registry) { ContainerRegistry::Registry.new('http://example.com', token: token) } - let(:repository) { registry.repository('group/test') } - let(:blob) { repository.blob(config) } it { expect(blob).to respond_to(:repository) } it { expect(blob).to delegate_method(:registry).to(:repository) } it { expect(blob).to delegate_method(:client).to(:repository) } - context '#path' do - subject { blob.path } - - it { is_expected.to eq('example.com/group/test@sha256:0123456789012345') } + describe '#path' do + it 'returns a valid path to the blob' do + expect(blob.path).to eq('group/test/image@sha256:0123456789012345') + end end - context '#digest' do - subject { blob.digest } - - it { is_expected.to eq(digest) } + describe '#digest' do + it 'return correct digest value' do + expect(blob.digest).to eq 'sha256:0123456789012345' + end end - context '#type' do - subject { blob.type } - - it { is_expected.to eq('binary') } + describe '#type' do + it 'returns a correct type' do + expect(blob.type).to eq 'binary' + end end - context '#revision' do - subject { blob.revision } - - it { is_expected.to eq('0123456789012345') } + describe '#revision' do + it 'returns a correct blob SHA' do + expect(blob.revision).to eq '0123456789012345' + end end - context '#short_revision' do - subject { blob.short_revision } - - it { is_expected.to eq('012345678') } + describe '#short_revision' do + it 'return a short SHA' do + expect(blob.short_revision).to eq '012345678' + end end - context '#delete' do + describe '#delete' do before do - stub_request(:delete, 'http://example.com/v2/group/test/blobs/sha256:0123456789012345'). - to_return(status: 200) + stub_request(:delete, 'http://registry.gitlab/v2/group/test/image/blobs/sha256:0123456789012345') + .to_return(status: 200) end - subject { blob.delete } - - it { is_expected.to be_truthy } + it 'returns true when blob has been successfuly deleted' do + expect(blob.delete).to be_truthy + end end - context '#data' do - let(:data) { '{"key":"value"}' } - - subject { blob.data } - + describe '#data' do context 'when locally stored' do before do - stub_request(:get, 'http://example.com/v2/group/test/blobs/sha256:0123456789012345'). + stub_request(:get, 'http://registry.gitlab/v2/group/test/image/blobs/sha256:0123456789012345'). to_return( status: 200, headers: { 'Content-Type' => 'application/json' }, - body: data) + body: '{"key":"value"}') end - it { is_expected.to eq(data) } + it 'returns a correct blob data' do + expect(blob.data).to eq '{"key":"value"}' + end end context 'when externally stored' do + let(:location) { 'http://external.com/blob/file' } + before do - stub_request(:get, 'http://example.com/v2/group/test/blobs/sha256:0123456789012345'). - with(headers: { 'Authorization' => "bearer #{token}" }). - to_return( + stub_request(:get, 'http://registry.gitlab/v2/group/test/image/blobs/sha256:0123456789012345') + .with(headers: { 'Authorization' => 'bearer token' }) + .to_return( status: 307, headers: { 'Location' => location }) end context 'for a valid address' do - let(:location) { 'http://external.com/blob/file' } - before do stub_request(:get, location). with(headers: { 'Authorization' => nil }). to_return( status: 200, headers: { 'Content-Type' => 'application/json' }, - body: data) + body: '{"key":"value"}') end - it { is_expected.to eq(data) } + it 'returns correct data' do + expect(blob.data).to eq '{"key":"value"}' + end end context 'for invalid file' do let(:location) { 'file:///etc/passwd' } - it { expect{ subject }.to raise_error(ArgumentError, 'invalid address') } + it 'raises an error' do + expect { blob.data }.to raise_error(ArgumentError, 'invalid address') + end end end end diff --git a/spec/lib/container_registry/path_spec.rb b/spec/lib/container_registry/path_spec.rb new file mode 100644 index 00000000000..b9c4572c269 --- /dev/null +++ b/spec/lib/container_registry/path_spec.rb @@ -0,0 +1,212 @@ +require 'spec_helper' + +describe ContainerRegistry::Path do + subject { described_class.new(path) } + + describe '#components' do + let(:path) { 'path/to/some/project' } + + it 'splits components by a forward slash' do + expect(subject.components).to eq %w[path to some project] + end + end + + describe '#nodes' do + context 'when repository path is valid' do + let(:path) { 'path/to/some/project' } + + it 'return all project path like node in reverse order' do + expect(subject.nodes).to eq %w[path/to/some/project + path/to/some + path/to] + end + end + + context 'when repository path is invalid' do + let(:path) { '' } + + it 'rasises en error' do + expect { subject.nodes } + .to raise_error described_class::InvalidRegistryPathError + end + end + end + + describe '#to_s' do + let(:path) { 'some/image' } + + it 'return a string with a repository path' do + expect(subject.to_s).to eq path + end + end + + describe '#valid?' do + context 'when path has less than two components' do + let(:path) { 'something/' } + + it { is_expected.not_to be_valid } + end + + context 'when path has more than allowed number of components' do + let(:path) { 'a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/r/s/t/u/w/y/z' } + + it { is_expected.not_to be_valid } + end + + context 'when path has invalid characters' do + let(:path) { 'some\path' } + + it { is_expected.not_to be_valid } + end + + context 'when path has two or more components' do + let(:path) { 'some/path' } + + it { is_expected.to be_valid } + end + + context 'when path is related to multi-level image' do + let(:path) { 'some/path/my/image' } + + it { is_expected.to be_valid } + end + end + + describe '#has_repository?' do + context 'when project exists' do + let(:project) { create(:empty_project) } + let(:path) { "#{project.full_path}/my/image" } + + context 'when path already has matching repository' do + before do + create(:container_repository, project: project, name: 'my/image') + end + + it { is_expected.to have_repository } + it { is_expected.to have_project } + end + + context 'when path does not have matching repository' do + it { is_expected.not_to have_repository } + it { is_expected.to have_project } + end + end + + context 'when project does not exist' do + let(:path) { 'some/project/my/image' } + + it { is_expected.not_to have_repository } + it { is_expected.not_to have_project } + end + end + + describe '#repository_project' do + let(:group) { create(:group, path: 'some_group') } + + context 'when project for given path exists' do + let(:path) { 'some_group/some_project' } + + before do + create(:empty_project, group: group, name: 'some_project') + create(:empty_project, name: 'some_project') + end + + it 'returns a correct project' do + expect(subject.repository_project.group).to eq group + end + end + + context 'when project for given path does not exist' do + let(:path) { 'not/matching' } + + it 'returns nil' do + expect(subject.repository_project).to be_nil + end + end + + context 'when matching multi-level path' do + let(:project) do + create(:empty_project, group: group, name: 'some_project') + end + + context 'when using the zero-level path' do + let(:path) { project.full_path } + + it 'supports zero-level path' do + expect(subject.repository_project).to eq project + end + end + + context 'when using first-level path' do + let(:path) { "#{project.full_path}/repository" } + + it 'supports first-level path' do + expect(subject.repository_project).to eq project + end + end + + context 'when using second-level path' do + let(:path) { "#{project.full_path}/repository/name" } + + it 'supports second-level path' do + expect(subject.repository_project).to eq project + end + end + + context 'when using too deep nesting in the path' do + let(:path) { "#{project.full_path}/repository/name/invalid" } + + it 'does not support three-levels of nesting' do + expect(subject.repository_project).to be_nil + end + end + end + end + + describe '#repository_name' do + context 'when project does not exist' do + let(:path) { 'some/name' } + + it 'returns nil' do + expect(subject.repository_name).to be_nil + end + end + + context 'when project exists' do + let(:group) { create(:group, path: 'some_group') } + + let(:project) do + create(:empty_project, group: group, name: 'some_project') + end + + before do + allow(path).to receive(:repository_project) + .and_return(project) + end + + context 'when project path equal repository path' do + let(:path) { 'some_group/some_project' } + + it 'returns an empty string' do + expect(subject.repository_name).to eq '' + end + end + + context 'when repository path has one additional level' do + let(:path) { 'some_group/some_project/repository' } + + it 'returns a correct repository name' do + expect(subject.repository_name).to eq 'repository' + end + end + + context 'when repository path has two additional levels' do + let(:path) { 'some_group/some_project/repository/image' } + + it 'returns a correct repository name' do + expect(subject.repository_name).to eq 'repository/image' + end + end + end + end +end diff --git a/spec/lib/container_registry/registry_spec.rb b/spec/lib/container_registry/registry_spec.rb index 4f3f8b24fc4..4d6eea94bf0 100644 --- a/spec/lib/container_registry/registry_spec.rb +++ b/spec/lib/container_registry/registry_spec.rb @@ -10,7 +10,7 @@ describe ContainerRegistry::Registry do it { is_expected.to respond_to(:uri) } it { is_expected.to respond_to(:path) } - it { expect(subject.repository('test')).not_to be_nil } + it { expect(subject).not_to be_nil } context '#path' do subject { registry.path } diff --git a/spec/lib/container_registry/repository_spec.rb b/spec/lib/container_registry/repository_spec.rb deleted file mode 100644 index c364e759108..00000000000 --- a/spec/lib/container_registry/repository_spec.rb +++ /dev/null @@ -1,65 +0,0 @@ -require 'spec_helper' - -describe ContainerRegistry::Repository do - let(:registry) { ContainerRegistry::Registry.new('http://example.com') } - let(:repository) { registry.repository('group/test') } - - it { expect(repository).to respond_to(:registry) } - it { expect(repository).to delegate_method(:client).to(:registry) } - it { expect(repository.tag('test')).not_to be_nil } - - context '#path' do - subject { repository.path } - - it { is_expected.to eq('example.com/group/test') } - end - - context 'manifest processing' do - before do - stub_request(:get, 'http://example.com/v2/group/test/tags/list'). - with(headers: { 'Accept' => 'application/vnd.docker.distribution.manifest.v2+json' }). - to_return( - status: 200, - body: JSON.dump(tags: ['test']), - headers: { 'Content-Type' => 'application/json' }) - end - - context '#manifest' do - subject { repository.manifest } - - it { is_expected.not_to be_nil } - end - - context '#valid?' do - subject { repository.valid? } - - it { is_expected.to be_truthy } - end - - context '#tags' do - subject { repository.tags } - - it { is_expected.not_to be_empty } - end - end - - context '#delete_tags' do - let(:tag) { ContainerRegistry::Tag.new(repository, 'tag') } - - before { expect(repository).to receive(:tags).twice.and_return([tag]) } - - subject { repository.delete_tags } - - context 'succeeds' do - before { expect(tag).to receive(:delete).and_return(true) } - - it { is_expected.to be_truthy } - end - - context 'any fails' do - before { expect(tag).to receive(:delete).and_return(false) } - - it { is_expected.to be_falsey } - end - end -end diff --git a/spec/lib/container_registry/tag_spec.rb b/spec/lib/container_registry/tag_spec.rb index c5e31ae82b6..bc1912d8e6c 100644 --- a/spec/lib/container_registry/tag_spec.rb +++ b/spec/lib/container_registry/tag_spec.rb @@ -1,25 +1,59 @@ require 'spec_helper' describe ContainerRegistry::Tag do - let(:registry) { ContainerRegistry::Registry.new('http://example.com') } - let(:repository) { registry.repository('group/test') } - let(:tag) { repository.tag('tag') } - let(:headers) { { 'Accept' => 'application/vnd.docker.distribution.manifest.v2+json' } } + let(:group) { create(:group, name: 'group') } + let(:project) { create(:project, path: 'test', group: group) } + + let(:repository) do + create(:container_repository, name: '', project: project) + end + + let(:headers) do + { 'Accept' => 'application/vnd.docker.distribution.manifest.v2+json' } + end + + let(:tag) { described_class.new(repository, 'tag') } + + before do + stub_container_registry_config(enabled: true, + api_url: 'http://registry.gitlab', + host_port: 'registry.gitlab') + end it { expect(tag).to respond_to(:repository) } it { expect(tag).to delegate_method(:registry).to(:repository) } it { expect(tag).to delegate_method(:client).to(:repository) } - context '#path' do - subject { tag.path } + describe '#path' do + context 'when tag belongs to zero-level repository' do + let(:repository) do + create(:container_repository, name: '', + tags: %w[rc1], + project: project) + end + + it 'returns path to the image' do + expect(tag.path).to eq('group/test:tag') + end + end + + context 'when tag belongs to first-level repository' do + let(:repository) do + create(:container_repository, name: 'my_image', + tags: %w[tag], + project: project) + end - it { is_expected.to eq('example.com/group/test:tag') } + it 'returns path to the image' do + expect(tag.path).to eq('group/test/my_image:tag') + end + end end context 'manifest processing' do context 'schema v1' do before do - stub_request(:get, 'http://example.com/v2/group/test/manifests/tag'). + stub_request(:get, 'http://registry.gitlab/v2/group/test/manifests/tag'). with(headers: headers). to_return( status: 200, @@ -56,7 +90,7 @@ describe ContainerRegistry::Tag do context 'schema v2' do before do - stub_request(:get, 'http://example.com/v2/group/test/manifests/tag'). + stub_request(:get, 'http://registry.gitlab/v2/group/test/manifests/tag'). with(headers: headers). to_return( status: 200, @@ -93,7 +127,7 @@ describe ContainerRegistry::Tag do context 'when locally stored' do before do - stub_request(:get, 'http://example.com/v2/group/test/blobs/sha256:d7a513a663c1a6dcdba9ed832ca53c02ac2af0c333322cd6ca92936d1d9917ac'). + stub_request(:get, 'http://registry.gitlab/v2/group/test/blobs/sha256:d7a513a663c1a6dcdba9ed832ca53c02ac2af0c333322cd6ca92936d1d9917ac'). with(headers: { 'Accept' => 'application/octet-stream' }). to_return( status: 200, @@ -105,7 +139,7 @@ describe ContainerRegistry::Tag do context 'when externally stored' do before do - stub_request(:get, 'http://example.com/v2/group/test/blobs/sha256:d7a513a663c1a6dcdba9ed832ca53c02ac2af0c333322cd6ca92936d1d9917ac'). + stub_request(:get, 'http://registry.gitlab/v2/group/test/blobs/sha256:d7a513a663c1a6dcdba9ed832ca53c02ac2af0c333322cd6ca92936d1d9917ac'). with(headers: { 'Accept' => 'application/octet-stream' }). to_return( status: 307, @@ -123,29 +157,29 @@ describe ContainerRegistry::Tag do end end - context 'manifest digest' do + context 'with stubbed digest' do before do - stub_request(:head, 'http://example.com/v2/group/test/manifests/tag'). - with(headers: headers). - to_return(status: 200, headers: { 'Docker-Content-Digest' => 'sha256:digest' }) + stub_request(:head, 'http://registry.gitlab/v2/group/test/manifests/tag') + .with(headers: headers) + .to_return(status: 200, headers: { 'Docker-Content-Digest' => 'sha256:digest' }) end - context '#digest' do - subject { tag.digest } - - it { is_expected.to eq('sha256:digest') } + describe '#digest' do + it 'returns a correct tag digest' do + expect(tag.digest).to eq 'sha256:digest' + end end - context '#delete' do + describe '#delete' do before do - stub_request(:delete, 'http://example.com/v2/group/test/manifests/sha256:digest'). - with(headers: headers). - to_return(status: 200) + stub_request(:delete, 'http://registry.gitlab/v2/group/test/manifests/sha256:digest') + .with(headers: headers) + .to_return(status: 200) end - subject { tag.delete } - - it { is_expected.to be_truthy } + it 'correctly deletes the tag' do + expect(tag.delete).to be_truthy + end end end end diff --git a/spec/lib/gitlab/ci/cron_parser_spec.rb b/spec/lib/gitlab/ci/cron_parser_spec.rb new file mode 100644 index 00000000000..0864bc7258d --- /dev/null +++ b/spec/lib/gitlab/ci/cron_parser_spec.rb @@ -0,0 +1,116 @@ +require 'spec_helper' + +describe Gitlab::Ci::CronParser do + shared_examples_for "returns time in the future" do + it { is_expected.to be > Time.now } + end + + describe '#next_time_from' do + subject { described_class.new(cron, cron_timezone).next_time_from(Time.now) } + + context 'when cron and cron_timezone are valid' do + context 'when specific time' do + let(:cron) { '3 4 5 6 *' } + let(:cron_timezone) { 'UTC' } + + it_behaves_like "returns time in the future" + + it 'returns exact time' do + expect(subject.min).to eq(3) + expect(subject.hour).to eq(4) + expect(subject.day).to eq(5) + expect(subject.month).to eq(6) + end + end + + context 'when specific day of week' do + let(:cron) { '* * * * 0' } + let(:cron_timezone) { 'UTC' } + + it_behaves_like "returns time in the future" + + it 'returns exact day of week' do + expect(subject.wday).to eq(0) + end + end + + context 'when slash used' do + let(:cron) { '*/10 */6 */10 */10 *' } + let(:cron_timezone) { 'UTC' } + + it_behaves_like "returns time in the future" + + it 'returns specific time' do + expect(subject.min).to be_in([0, 10, 20, 30, 40, 50]) + expect(subject.hour).to be_in([0, 6, 12, 18]) + expect(subject.day).to be_in([1, 11, 21, 31]) + expect(subject.month).to be_in([1, 11]) + end + end + + context 'when range used' do + let(:cron) { '0,20,40 * 1-5 * *' } + let(:cron_timezone) { 'UTC' } + + it_behaves_like "returns time in the future" + + it 'returns specific time' do + expect(subject.min).to be_in([0, 20, 40]) + expect(subject.day).to be_in((1..5).to_a) + end + end + + context 'when cron_timezone is US/Pacific' do + let(:cron) { '0 0 * * *' } + let(:cron_timezone) { 'US/Pacific' } + + it_behaves_like "returns time in the future" + + it 'converts time in server time zone' do + expect(subject.hour).to eq((Time.zone.now.in_time_zone(cron_timezone).utc_offset / 60 / 60).abs) + end + end + end + + context 'when cron and cron_timezone are invalid' do + let(:cron) { 'invalid_cron' } + let(:cron_timezone) { 'invalid_cron_timezone' } + + it 'returns nil' do + is_expected.to be_nil + end + end + end + + describe '#cron_valid?' do + subject { described_class.new(cron, Gitlab::Ci::CronParser::VALID_SYNTAX_SAMPLE_TIME_ZONE).cron_valid? } + + context 'when cron is valid' do + let(:cron) { '* * * * *' } + + it { is_expected.to eq(true) } + end + + context 'when cron is invalid' do + let(:cron) { '*********' } + + it { is_expected.to eq(false) } + end + end + + describe '#cron_timezone_valid?' do + subject { described_class.new(Gitlab::Ci::CronParser::VALID_SYNTAX_SAMPLE_CRON, cron_timezone).cron_timezone_valid? } + + context 'when cron is valid' do + let(:cron_timezone) { 'Europe/Istanbul' } + + it { is_expected.to eq(true) } + end + + context 'when cron is invalid' do + let(:cron_timezone) { 'Invalid-zone' } + + it { is_expected.to eq(false) } + end + end +end diff --git a/spec/lib/gitlab/ci/trace/stream_spec.rb b/spec/lib/gitlab/ci/trace/stream_spec.rb new file mode 100644 index 00000000000..f1a1a71c528 --- /dev/null +++ b/spec/lib/gitlab/ci/trace/stream_spec.rb @@ -0,0 +1,201 @@ +require 'spec_helper' + +describe Gitlab::Ci::Trace::Stream do + describe 'delegates' do + subject { described_class.new { nil } } + + it { is_expected.to delegate_method(:close).to(:stream) } + it { is_expected.to delegate_method(:tell).to(:stream) } + it { is_expected.to delegate_method(:seek).to(:stream) } + it { is_expected.to delegate_method(:size).to(:stream) } + it { is_expected.to delegate_method(:path).to(:stream) } + it { is_expected.to delegate_method(:truncate).to(:stream) } + it { is_expected.to delegate_method(:valid?).to(:stream).as(:present?) } + it { is_expected.to delegate_method(:file?).to(:path).as(:present?) } + end + + describe '#limit' do + let(:stream) do + described_class.new do + StringIO.new("12345678") + end + end + + it 'if size is larger we start from beggining' do + stream.limit(10) + + expect(stream.tell).to eq(0) + end + + it 'if size is smaller we start from the end' do + stream.limit(2) + + expect(stream.tell).to eq(6) + end + end + + describe '#append' do + let(:stream) do + described_class.new do + StringIO.new("12345678") + end + end + + it "truncates and append content" do + stream.append("89", 4) + stream.seek(0) + + expect(stream.size).to eq(6) + expect(stream.raw).to eq("123489") + end + end + + describe '#set' do + let(:stream) do + described_class.new do + StringIO.new("12345678") + end + end + + before do + stream.set("8901") + end + + it "overwrite content" do + stream.seek(0) + + expect(stream.size).to eq(4) + expect(stream.raw).to eq("8901") + end + end + + describe '#raw' do + let(:path) { __FILE__ } + let(:lines) { File.readlines(path) } + let(:stream) do + described_class.new do + File.open(path) + end + end + + it 'returns all contents if last_lines is not specified' do + result = stream.raw + + expect(result).to eq(lines.join) + expect(result.encoding).to eq(Encoding.default_external) + end + + context 'limit max lines' do + before do + # specifying BUFFER_SIZE forces to seek backwards + allow(described_class).to receive(:BUFFER_SIZE) + .and_return(2) + end + + it 'returns last few lines' do + result = stream.raw(last_lines: 2) + + expect(result).to eq(lines.last(2).join) + expect(result.encoding).to eq(Encoding.default_external) + end + + it 'returns everything if trying to get too many lines' do + result = stream.raw(last_lines: lines.size * 2) + + expect(result).to eq(lines.join) + expect(result.encoding).to eq(Encoding.default_external) + end + end + end + + describe '#html_with_state' do + let(:stream) do + described_class.new do + StringIO.new("1234") + end + end + + it 'returns html content with state' do + result = stream.html_with_state + + expect(result.html).to eq("1234") + end + + context 'follow-up state' do + let!(:last_result) { stream.html_with_state } + + before do + stream.append("5678", 4) + stream.seek(0) + end + + it "returns appended trace" do + result = stream.html_with_state(last_result.state) + + expect(result.append).to be_truthy + expect(result.html).to eq("5678") + end + end + end + + describe '#html' do + let(:stream) do + described_class.new do + StringIO.new("12\n34\n56") + end + end + + it "returns html" do + expect(stream.html).to eq("12<br>34<br>56") + end + + it "returns html for last line only" do + expect(stream.html(last_lines: 1)).to eq("56") + end + end + + describe '#extract_coverage' do + let(:stream) do + described_class.new do + StringIO.new(data) + end + end + + subject { stream.extract_coverage(regex) } + + context 'valid content & regex' do + let(:data) { 'Coverage 1033 / 1051 LOC (98.29%) covered' } + let(:regex) { '\(\d+.\d+\%\) covered' } + + it { is_expected.to eq(98.29) } + end + + context 'valid content & bad regex' do + let(:data) { 'Coverage 1033 / 1051 LOC (98.29%) covered\n' } + let(:regex) { 'very covered' } + + it { is_expected.to be_nil } + end + + context 'no coverage content & regex' do + let(:data) { 'No coverage for today :sad:' } + let(:regex) { '\(\d+.\d+\%\) covered' } + + it { is_expected.to be_nil } + end + + context 'multiple results in content & regex' do + let(:data) { ' (98.39%) covered. (98.29%) covered' } + let(:regex) { '\(\d+.\d+\%\) covered' } + + it { is_expected.to eq(98.29) } + end + + context 'using a regex capture' do + let(:data) { 'TOTAL 9926 3489 65%' } + let(:regex) { 'TOTAL\s+\d+\s+\d+\s+(\d{1,3}\%)' } + + it { is_expected.to eq(65) } + end + end +end diff --git a/spec/lib/gitlab/ci/trace_reader_spec.rb b/spec/lib/gitlab/ci/trace_reader_spec.rb deleted file mode 100644 index ff5551bf703..00000000000 --- a/spec/lib/gitlab/ci/trace_reader_spec.rb +++ /dev/null @@ -1,52 +0,0 @@ -require 'spec_helper' - -describe Gitlab::Ci::TraceReader do - let(:path) { __FILE__ } - let(:lines) { File.readlines(path) } - let(:bytesize) { lines.sum(&:bytesize) } - - it 'returns last few lines' do - 10.times do - subject = build_subject - last_lines = random_lines - - expected = lines.last(last_lines).join - result = subject.read(last_lines: last_lines) - - expect(result).to eq(expected) - expect(result.encoding).to eq(Encoding.default_external) - end - end - - it 'returns everything if trying to get too many lines' do - result = build_subject.read(last_lines: lines.size * 2) - - expect(result).to eq(lines.join) - expect(result.encoding).to eq(Encoding.default_external) - end - - it 'returns all contents if last_lines is not specified' do - result = build_subject.read - - expect(result).to eq(lines.join) - expect(result.encoding).to eq(Encoding.default_external) - end - - it 'raises an error if not passing an integer for last_lines' do - expect do - build_subject.read(last_lines: lines) - end.to raise_error(ArgumentError) - end - - def random_lines - Random.rand(lines.size) + 1 - end - - def random_buffer - Random.rand(bytesize) + 1 - end - - def build_subject - described_class.new(__FILE__, buffer_size: random_buffer) - end -end diff --git a/spec/lib/gitlab/ci/trace_spec.rb b/spec/lib/gitlab/ci/trace_spec.rb new file mode 100644 index 00000000000..69e8dc9220d --- /dev/null +++ b/spec/lib/gitlab/ci/trace_spec.rb @@ -0,0 +1,216 @@ +require 'spec_helper' + +describe Gitlab::Ci::Trace do + let(:build) { create(:ci_build) } + let(:trace) { described_class.new(build) } + + describe "associations" do + it { expect(trace).to respond_to(:job) } + it { expect(trace).to delegate_method(:old_trace).to(:job) } + end + + describe '#html' do + before do + trace.set("12\n34") + end + + it "returns formatted html" do + expect(trace.html).to eq("12<br>34") + end + + it "returns last line of formatted html" do + expect(trace.html(last_lines: 1)).to eq("34") + end + end + + describe '#raw' do + before do + trace.set("12\n34") + end + + it "returns raw output" do + expect(trace.raw).to eq("12\n34") + end + + it "returns last line of raw output" do + expect(trace.raw(last_lines: 1)).to eq("34") + end + end + + describe '#extract_coverage' do + let(:regex) { '\(\d+.\d+\%\) covered' } + + before do + trace.set('Coverage 1033 / 1051 LOC (98.29%) covered') + end + + it "returns valid coverage" do + expect(trace.extract_coverage(regex)).to eq(98.29) + end + end + + describe '#set' do + before do + trace.set("12") + end + + it "returns trace" do + expect(trace.raw).to eq("12") + end + + context 'overwrite trace' do + before do + trace.set("34") + end + + it "returns new trace" do + expect(trace.raw).to eq("34") + end + end + + context 'runners token' do + let(:token) { 'my_secret_token' } + + before do + build.project.update(runners_token: token) + trace.set(token) + end + + it "hides token" do + expect(trace.raw).not_to include(token) + end + end + + context 'hides build token' do + let(:token) { 'my_secret_token' } + + before do + build.update(token: token) + trace.set(token) + end + + it "hides token" do + expect(trace.raw).not_to include(token) + end + end + end + + describe '#append' do + before do + trace.set("1234") + end + + it "returns correct trace" do + expect(trace.append("56", 4)).to eq(6) + expect(trace.raw).to eq("123456") + end + + context 'tries to append trace at different offset' do + it "fails with append" do + expect(trace.append("56", 2)).to eq(-4) + expect(trace.raw).to eq("1234") + end + end + + context 'runners token' do + let(:token) { 'my_secret_token' } + + before do + build.project.update(runners_token: token) + trace.append(token, 0) + end + + it "hides token" do + expect(trace.raw).not_to include(token) + end + end + + context 'build token' do + let(:token) { 'my_secret_token' } + + before do + build.update(token: token) + trace.append(token, 0) + end + + it "hides token" do + expect(trace.raw).not_to include(token) + end + end + end + + describe 'trace handling' do + context 'trace does not exist' do + it { expect(trace.exist?).to be(false) } + end + + context 'new trace path is used' do + before do + trace.send(:ensure_directory) + + File.open(trace.send(:default_path), "w") do |file| + file.write("data") + end + end + + it "trace exist" do + expect(trace.exist?).to be(true) + end + + it "can be erased" do + trace.erase! + expect(trace.exist?).to be(false) + end + end + + context 'deprecated path' do + let(:path) { trace.send(:deprecated_path) } + + context 'with valid ci_id' do + before do + build.project.update(ci_id: 1000) + + FileUtils.mkdir_p(File.dirname(path)) + + File.open(path, "w") do |file| + file.write("data") + end + end + + it "trace exist" do + expect(trace.exist?).to be(true) + end + + it "can be erased" do + trace.erase! + expect(trace.exist?).to be(false) + end + end + + context 'without valid ci_id' do + it "does not return deprecated path" do + expect(path).to be_nil + end + end + end + + context 'stored in database' do + before do + build.send(:write_attribute, :trace, "data") + end + + it "trace exist" do + expect(trace.exist?).to be(true) + end + + it "can be erased" do + trace.erase! + expect(trace.exist?).to be(false) + end + + it "returns database data" do + expect(trace.raw).to eq("data") + end + end + end +end diff --git a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb index b300feaabe1..3f79eaf7afb 100644 --- a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb +++ b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb @@ -143,6 +143,7 @@ describe Gitlab::Email::Handler::CreateNoteHandler, lib: true do expect(new_note.author).to eq(sent_notification.recipient) expect(new_note.position).to eq(note.position) expect(new_note.note).to include("I could not disagree more.") + expect(new_note.in_reply_to?(note)).to be_truthy end it "adds all attachments" do diff --git a/spec/lib/gitlab/etag_caching/middleware_spec.rb b/spec/lib/gitlab/etag_caching/middleware_spec.rb index 6ec4360adc2..c872d8232b0 100644 --- a/spec/lib/gitlab/etag_caching/middleware_spec.rb +++ b/spec/lib/gitlab/etag_caching/middleware_spec.rb @@ -47,9 +47,9 @@ describe Gitlab::EtagCaching::Middleware do it 'tracks "etag_caching_key_not_found" event' do expect(Gitlab::Metrics).to receive(:add_event) - .with(:etag_caching_middleware_used) + .with(:etag_caching_middleware_used, endpoint: 'issue_notes') expect(Gitlab::Metrics).to receive(:add_event) - .with(:etag_caching_key_not_found) + .with(:etag_caching_key_not_found, endpoint: 'issue_notes') middleware.call(build_env(path, if_none_match)) end @@ -93,9 +93,9 @@ describe Gitlab::EtagCaching::Middleware do it 'tracks "etag_caching_cache_hit" event' do expect(Gitlab::Metrics).to receive(:add_event) - .with(:etag_caching_middleware_used) + .with(:etag_caching_middleware_used, endpoint: 'issue_notes') expect(Gitlab::Metrics).to receive(:add_event) - .with(:etag_caching_cache_hit) + .with(:etag_caching_cache_hit, endpoint: 'issue_notes') middleware.call(build_env(path, if_none_match)) end @@ -132,9 +132,9 @@ describe Gitlab::EtagCaching::Middleware do mock_app_response expect(Gitlab::Metrics).to receive(:add_event) - .with(:etag_caching_middleware_used) + .with(:etag_caching_middleware_used, endpoint: 'issue_notes') expect(Gitlab::Metrics).to receive(:add_event) - .with(:etag_caching_resource_changed) + .with(:etag_caching_resource_changed, endpoint: 'issue_notes') middleware.call(build_env(path, if_none_match)) end @@ -150,9 +150,9 @@ describe Gitlab::EtagCaching::Middleware do it 'tracks "etag_caching_header_missing" event' do expect(Gitlab::Metrics).to receive(:add_event) - .with(:etag_caching_middleware_used) + .with(:etag_caching_middleware_used, endpoint: 'issue_notes') expect(Gitlab::Metrics).to receive(:add_event) - .with(:etag_caching_header_missing) + .with(:etag_caching_header_missing, endpoint: 'issue_notes') middleware.call(build_env(path, if_none_match)) end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 24654bf6afd..5aa2a6d4b1b 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -99,6 +99,9 @@ triggers: - project - trigger_requests - owner +- trigger_schedule +trigger_schedule: +- trigger deploy_keys: - user - deploy_keys_projects @@ -116,6 +119,9 @@ merge_access_levels: - protected_branch push_access_levels: - protected_branch +container_repositories: +- project +- name project: - taggings - base_tags @@ -143,6 +149,7 @@ project: - asana_service - gemnasium_service - slack_service +- microsoft_teams_service - mattermost_service - buildkite_service - bamboo_service @@ -194,6 +201,7 @@ project: - runners - variables - triggers +- trigger_schedules - environments - deployments - project_feature @@ -202,6 +210,7 @@ project: - project_authorizations - route - statistics +- container_repositories - uploads award_emoji: - awardable diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index 1ad16a9b57d..0c43c5662e8 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -240,6 +240,17 @@ Ci::Trigger: - updated_at - owner_id - description +- ref +Ci::TriggerSchedule: +- id +- project_id +- trigger_id +- deleted_at +- created_at +- updated_at +- cron +- cron_timezone +- next_run_at DeployKey: - id - user_id diff --git a/spec/lib/microsoft_teams/activity_spec.rb b/spec/lib/microsoft_teams/activity_spec.rb new file mode 100644 index 00000000000..7890ae2e7b0 --- /dev/null +++ b/spec/lib/microsoft_teams/activity_spec.rb @@ -0,0 +1,16 @@ +require 'spec_helper' + +describe MicrosoftTeams::Activity do + subject { described_class.new(title: 'title', subtitle: 'subtitle', text: 'text', image: 'image') } + + describe '#prepare' do + it 'returns the correct JSON object' do + expect(subject.prepare).to eq({ + 'activityTitle' => 'title', + 'activitySubtitle' => 'subtitle', + 'activityText' => 'text', + 'activityImage' => 'image' + }) + end + end +end diff --git a/spec/lib/microsoft_teams/notifier_spec.rb b/spec/lib/microsoft_teams/notifier_spec.rb new file mode 100644 index 00000000000..3035693812f --- /dev/null +++ b/spec/lib/microsoft_teams/notifier_spec.rb @@ -0,0 +1,55 @@ +require 'spec_helper' + +describe MicrosoftTeams::Notifier do + subject { described_class.new(webhook_url) } + + let(:webhook_url) { 'https://example.gitlab.com/'} + let(:header) { { 'Content-Type' => 'application/json' } } + let(:options) do + { + title: 'JohnDoe4/project2', + pretext: '[[JohnDoe4/project2](http://localhost/namespace2/gitlabhq)] Issue [#1 Awesome issue](http://localhost/namespace2/gitlabhq/issues/1) opened by user6', + activity: { + title: 'Issue opened by user6', + subtitle: 'in [JohnDoe4/project2](http://localhost/namespace2/gitlabhq)', + text: '[#1 Awesome issue](http://localhost/namespace2/gitlabhq/issues/1)', + image: 'http://someimage.com' + }, + attachments: 'please fix' + } + end + + let(:body) do + { + 'sections' => [ + { + 'activityTitle' => 'Issue opened by user6', + 'activitySubtitle' => 'in [JohnDoe4/project2](http://localhost/namespace2/gitlabhq)', + 'activityText' => '[#1 Awesome issue](http://localhost/namespace2/gitlabhq/issues/1)', + 'activityImage' => 'http://someimage.com' + }, + { + 'title' => 'Details', + 'facts' => [ + { + 'name' => 'Attachments', + 'value' => 'please fix' + } + ] + } + ], + 'title' => 'JohnDoe4/project2', + 'summary' => '[[JohnDoe4/project2](http://localhost/namespace2/gitlabhq)] Issue [#1 Awesome issue](http://localhost/namespace2/gitlabhq/issues/1) opened by user6' + } + end + + describe '#ping' do + before do + stub_request(:post, webhook_url).with(body: JSON(body), headers: { 'Content-Type' => 'application/json' }).to_return(status: 200, body: "", headers: {}) + end + + it 'expects to receive successfull answer' do + expect(subject.ping(options)).to be true + end + end +end diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index 6a89b007f96..e6f0a3b5920 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -63,7 +63,7 @@ describe Notify do it 'contains a link to note author' do is_expected.to have_html_escaped_body_text(issue.author_name) - is_expected.to have_body_text 'wrote:' + is_expected.to have_body_text 'created an issue:' end end end @@ -215,7 +215,7 @@ describe Notify do it 'contains a link to note author' do is_expected.to have_html_escaped_body_text merge_request.author_name - is_expected.to have_body_text 'wrote:' + is_expected.to have_body_text 'created a merge request:' end end end @@ -554,7 +554,7 @@ describe Notify do end it 'does not contain note author' do - is_expected.not_to have_body_text 'wrote:' + is_expected.not_to have_body_text note.author_name end context 'when enabled email_author_in_body' do @@ -564,7 +564,6 @@ describe Notify do it 'contains a link to note author' do is_expected.to have_html_escaped_body_text note.author_name - is_expected.to have_body_text 'wrote:' end end end @@ -637,7 +636,7 @@ describe Notify do end end - context 'items that are noteable, emails for a note on a diff' do + context 'items that are noteable, the email for a discussion note' do let(:project) { create(:project, :repository) } let(:note_author) { create(:user, name: 'author_name') } @@ -645,8 +644,118 @@ describe Notify do allow(Note).to receive(:find).with(note.id).and_return(note) end - shared_examples 'a note email on a diff' do |model| - let(:note) { create(model, project: project, author: note_author) } + shared_examples 'a discussion note email' do |model| + it_behaves_like 'it should have Gmail Actions links' + + it 'is sent to the given recipient as the author' do + sender = subject.header[:from].addrs[0] + + aggregate_failures do + expect(sender.display_name).to eq(note_author.name) + expect(sender.address).to eq(gitlab_sender) + expect(subject).to deliver_to(recipient.notification_email) + end + end + + it 'contains the message from the note' do + is_expected.to have_body_text note.note + end + + it 'contains an introduction' do + is_expected.to have_body_text 'started a new discussion' + end + + context 'when a comment on an existing discussion' do + let!(:second_note) { create(model, author: note_author, noteable: nil, in_reply_to: note) } + + it 'contains an introduction' do + is_expected.to have_body_text 'commented on a' + end + end + end + + describe 'on a commit' do + let(:commit) { project.commit } + let(:note) { create(:discussion_note_on_commit, commit_id: commit.id, project: project, author: note_author) } + + before(:each) { allow(note).to receive(:noteable).and_return(commit) } + + subject { Notify.note_commit_email(recipient.id, note.id) } + + it_behaves_like 'a discussion note email', :discussion_note_on_commit + it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do + let(:model) { commit } + end + it_behaves_like 'it should show Gmail Actions View Commit link' + it_behaves_like 'a user cannot unsubscribe through footer link' + + it 'has the correct subject' do + is_expected.to have_subject "Re: #{project.name} | #{commit.title.strip} (#{commit.short_id})" + end + + it 'contains a link to the commit' do + is_expected.to have_body_text commit.short_id + end + end + + describe 'on a merge request' do + let(:merge_request) { create(:merge_request, source_project: project, target_project: project) } + let(:note) { create(:discussion_note_on_merge_request, noteable: merge_request, project: project, author: note_author) } + let(:note_on_merge_request_path) { namespace_project_merge_request_path(project.namespace, project, merge_request, anchor: "note_#{note.id}") } + before(:each) { allow(note).to receive(:noteable).and_return(merge_request) } + + subject { Notify.note_merge_request_email(recipient.id, note.id) } + + it_behaves_like 'a discussion note email', :discussion_note_on_merge_request + it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do + let(:model) { merge_request } + end + it_behaves_like 'it should show Gmail Actions View Merge request link' + it_behaves_like 'an unsubscribeable thread' + + it 'has the correct subject' do + is_expected.to have_referable_subject(merge_request, reply: true) + end + + it 'contains a link to the merge request note' do + is_expected.to have_body_text note_on_merge_request_path + end + end + + describe 'on an issue' do + let(:issue) { create(:issue, project: project) } + let(:note) { create(:discussion_note_on_issue, noteable: issue, project: project, author: note_author) } + let(:note_on_issue_path) { namespace_project_issue_path(project.namespace, project, issue, anchor: "note_#{note.id}") } + before(:each) { allow(note).to receive(:noteable).and_return(issue) } + + subject { Notify.note_issue_email(recipient.id, note.id) } + + it_behaves_like 'a discussion note email', :discussion_note_on_issue + it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do + let(:model) { issue } + end + it_behaves_like 'it should show Gmail Actions View Issue link' + it_behaves_like 'an unsubscribeable thread' + + it 'has the correct subject' do + is_expected.to have_referable_subject(issue, reply: true) + end + + it 'contains a link to the issue note' do + is_expected.to have_body_text note_on_issue_path + end + end + end + + context 'items that are noteable, the email for a diff discussion note' do + let(:note_author) { create(:user, name: 'author_name') } + + before :each do + allow(Note).to receive(:find).with(note.id).and_return(note) + end + + shared_examples 'an email for a note on a diff discussion' do |model| + let(:note) { create(model, author: note_author) } it "includes diffs with character-level highlighting" do is_expected.to have_body_text '<span class="p">}</span></span>' @@ -672,18 +781,15 @@ describe Notify do is_expected.to have_html_escaped_body_text note.note end - it 'does not contain note author' do - is_expected.not_to have_body_text 'wrote:' + it 'contains an introduction' do + is_expected.to have_body_text 'started a new discussion on' end - context 'when enabled email_author_in_body' do - before do - stub_application_setting(email_author_in_body: true) - end + context 'when a comment on an existing discussion' do + let!(:second_note) { create(model, author: note_author, noteable: nil, in_reply_to: note) } - it 'contains a link to note author' do - is_expected.to have_html_escaped_body_text note.author_name - is_expected.to have_body_text 'wrote:' + it 'contains an introduction' do + is_expected.to have_body_text 'commented on a discussion on' end end end @@ -694,7 +800,7 @@ describe Notify do subject { Notify.note_commit_email(recipient.id, note.id) } - it_behaves_like 'a note email on a diff', :diff_note_on_commit + it_behaves_like 'an email for a note on a diff discussion', :diff_note_on_commit it_behaves_like 'it should show Gmail Actions View Commit link' it_behaves_like 'a user cannot unsubscribe through footer link' end @@ -705,7 +811,7 @@ describe Notify do subject { Notify.note_merge_request_email(recipient.id, note.id) } - it_behaves_like 'a note email on a diff', :diff_note_on_merge_request + it_behaves_like 'an email for a note on a diff discussion', :diff_note_on_merge_request it_behaves_like 'it should show Gmail Actions View Merge request link' it_behaves_like 'an unsubscribeable thread' end diff --git a/spec/mailers/previews/notify_preview.rb b/spec/mailers/previews/notify_preview.rb index 0e1ccb5b847..580f0d56a92 100644 --- a/spec/mailers/previews/notify_preview.rb +++ b/spec/mailers/previews/notify_preview.rb @@ -1,4 +1,100 @@ class NotifyPreview < ActionMailer::Preview + def note_merge_request_email_for_individual_note + note_email(:note_merge_request_email) do + note = <<-MD.strip_heredoc + This is an individual note on a merge request :smiley: + + In this notification email, we expect to see: + + - The note contents (that's what you're looking at) + - A link to view this note on Gitlab + - An explanation for why the user is receiving this notification + MD + + create_note(noteable_type: 'merge_request', noteable_id: merge_request.id, note: note) + end + end + + def note_merge_request_email_for_discussion + note_email(:note_merge_request_email) do + note = <<-MD.strip_heredoc + This is a new discussion on a merge request :smiley: + + In this notification email, we expect to see: + + - A line saying who started this discussion + - The note contents (that's what you're looking at) + - A link to view this discussion on Gitlab + - An explanation for why the user is receiving this notification + MD + + create_note(noteable_type: 'merge_request', noteable_id: merge_request.id, type: 'DiscussionNote', note: note) + end + end + + def note_merge_request_email_for_diff_discussion + note_email(:note_merge_request_email) do + note = <<-MD.strip_heredoc + This is a new discussion on a merge request :smiley: + + In this notification email, we expect to see: + + - A line saying who started this discussion and on what file + - The diff + - The note contents (that's what you're looking at) + - A link to view this discussion on Gitlab + - An explanation for why the user is receiving this notification + MD + + position = Gitlab::Diff::Position.new( + old_path: "files/ruby/popen.rb", + new_path: "files/ruby/popen.rb", + old_line: nil, + new_line: 14, + diff_refs: merge_request.diff_refs + ) + + create_note(noteable_type: 'merge_request', noteable_id: merge_request.id, type: 'DiffNote', position: position, note: note) + end + end + + private + + def project + @project ||= Project.find_by_full_path('gitlab-org/gitlab-test') + end + + def merge_request + @merge_request ||= project.merge_requests.find_by(source_branch: 'master', target_branch: 'feature') + end + + def user + @user ||= User.last + end + + def create_note(params) + Notes::CreateService.new(project, user, params).execute + end + + def note_email(method) + cleanup do + note = yield + + Notify.public_send(method, user.id, note) + end + end + + def cleanup + email = nil + + ActiveRecord::Base.transaction do + email = yield + raise ActiveRecord::Rollback + end + + email + end + def pipeline_success_email pipeline = Ci::Pipeline.last Notify.pipeline_success_email(pipeline, pipeline.user.try(:email)) diff --git a/spec/models/award_emoji_spec.rb b/spec/models/award_emoji_spec.rb index cb3c592f8cd..2a9a27752c1 100644 --- a/spec/models/award_emoji_spec.rb +++ b/spec/models/award_emoji_spec.rb @@ -25,6 +25,20 @@ describe AwardEmoji, models: true do expect(new_award).not_to be_valid end + + # Assume User A and User B both created award emoji of the same name + # on the same awardable. When User A is deleted, User A's award emoji + # is moved to the ghost user. When User B is deleted, User B's award emoji + # also needs to be moved to the ghost user - this cannot happen unless + # the uniqueness validation is disabled for ghost users. + it "allows duplicate award emoji for ghost users" do + user = create(:user, :ghost) + issue = create(:issue) + create(:award_emoji, user: user, awardable: issue) + new_award = build(:award_emoji, user: user, awardable: issue) + + expect(new_award).to be_valid + end end end end diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 8dbcf50ee0c..8601160561f 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -17,8 +17,9 @@ describe Ci::Build, :models do it { is_expected.to belong_to(:trigger_request) } it { is_expected.to belong_to(:erased_by) } it { is_expected.to have_many(:deployments) } - it { is_expected.to validate_presence_of :ref } - it { is_expected.to respond_to :trace_html } + it { is_expected.to validate_presence_of(:ref) } + it { is_expected.to respond_to(:has_trace?) } + it { is_expected.to respond_to(:trace) } describe '#actionize' do context 'when build is a created' do @@ -78,32 +79,6 @@ describe Ci::Build, :models do end end - describe '#append_trace' do - subject { build.trace_html } - - context 'when build.trace hides runners token' do - let(:token) { 'my_secret_token' } - - before do - build.project.update(runners_token: token) - build.append_trace(token, 0) - end - - it { is_expected.not_to include(token) } - end - - context 'when build.trace hides build token' do - let(:token) { 'my_secret_token' } - - before do - build.update(token: token) - build.append_trace(token, 0) - end - - it { is_expected.not_to include(token) } - end - end - describe '#artifacts?' do subject { build.artifacts? } @@ -272,12 +247,98 @@ describe Ci::Build, :models do describe '#update_coverage' do context "regarding coverage_regex's value," do - it "saves the correct extracted coverage value" do + before do build.coverage_regex = '\(\d+.\d+\%\) covered' - allow(build).to receive(:trace) { 'Coverage 1033 / 1051 LOC (98.29%) covered' } - expect(build).to receive(:update_attributes).with(coverage: 98.29) { true } - expect(build.update_coverage).to be true + build.trace.set('Coverage 1033 / 1051 LOC (98.29%) covered') + end + + it "saves the correct extracted coverage value" do + expect(build.update_coverage).to be(true) + expect(build.coverage).to eq(98.29) + end + end + end + + describe '#trace' do + subject { build.trace } + + it { is_expected.to be_a(Gitlab::Ci::Trace) } + end + + describe '#has_trace?' do + subject { build.has_trace? } + + it "expect to call exist? method" do + expect_any_instance_of(Gitlab::Ci::Trace).to receive(:exist?) + .and_return(true) + + is_expected.to be(true) + end + end + + describe '#trace=' do + it "expect to fail trace=" do + expect { build.trace = "new" }.to raise_error(NotImplementedError) + end + end + + describe '#old_trace' do + subject { build.old_trace } + + before do + build.update_column(:trace, 'old trace') + end + + it "expect to receive data from database" do + is_expected.to eq('old trace') + end + end + + describe '#erase_old_trace!' do + subject { build.send(:read_attribute, :trace) } + + before do + build.send(:write_attribute, :trace, 'old trace') + end + + it "expect to receive data from database" do + build.erase_old_trace! + + is_expected.to be_nil + end + end + + describe '#hide_secrets' do + let(:subject) { build.hide_secrets(data) } + + context 'hide runners token' do + let(:data) { 'new token data'} + + before do + build.project.update(runners_token: 'token') end + + it { is_expected.to eq('new xxxxx data') } + end + + context 'hide build token' do + let(:data) { 'new token data'} + + before do + build.update(token: 'token') + end + + it { is_expected.to eq('new xxxxx data') } + end + + context 'hide build token' do + let(:data) { 'new token data'} + + before do + build.update(token: 'token') + end + + it { is_expected.to eq('new xxxxx data') } end end @@ -438,7 +499,7 @@ describe Ci::Build, :models do end it 'erases build trace in trace file' do - expect(build.trace).to be_empty + expect(build).not_to have_trace end it 'sets erased to true' do @@ -532,38 +593,6 @@ describe Ci::Build, :models do end end - describe '#extract_coverage' do - context 'valid content & regex' do - subject { build.extract_coverage('Coverage 1033 / 1051 LOC (98.29%) covered', '\(\d+.\d+\%\) covered') } - - it { is_expected.to eq(98.29) } - end - - context 'valid content & bad regex' do - subject { build.extract_coverage('Coverage 1033 / 1051 LOC (98.29%) covered', 'very covered') } - - it { is_expected.to be_nil } - end - - context 'no coverage content & regex' do - subject { build.extract_coverage('No coverage for today :sad:', '\(\d+.\d+\%\) covered') } - - it { is_expected.to be_nil } - end - - context 'multiple results in content & regex' do - subject { build.extract_coverage(' (98.39%) covered. (98.29%) covered', '\(\d+.\d+\%\) covered') } - - it { is_expected.to eq(98.29) } - end - - context 'using a regex capture' do - subject { build.extract_coverage('TOTAL 9926 3489 65%', 'TOTAL\s+\d+\s+\d+\s+(\d{1,3}\%)') } - - it { is_expected.to eq(65) } - end - end - describe '#first_pending' do let!(:first) { create(:ci_build, pipeline: pipeline, status: 'pending', created_at: Date.yesterday) } let!(:second) { create(:ci_build, pipeline: pipeline, status: 'pending') } @@ -983,32 +1012,6 @@ describe Ci::Build, :models do it { is_expected.to eq(project.name) } end - describe '#raw_trace' do - subject { build.raw_trace } - - context 'when build.trace hides runners token' do - let(:token) { 'my_secret_token' } - - before do - build.project.update(runners_token: token) - build.update(trace: token) - end - - it { is_expected.not_to include(token) } - end - - context 'when build.trace hides build token' do - let(:token) { 'my_secret_token' } - - before do - build.update(token: token) - build.update(trace: token) - end - - it { is_expected.not_to include(token) } - end - end - describe '#ref_slug' do { 'master' => 'master', @@ -1074,61 +1077,6 @@ describe Ci::Build, :models do end end - describe '#trace' do - it 'obfuscates project runners token' do - allow(build).to receive(:raw_trace).and_return("Test: #{build.project.runners_token}") - - expect(build.trace).to eq("Test: xxxxxxxxxxxxxxxxxxxx") - end - - it 'empty project runners token' do - allow(build).to receive(:raw_trace).and_return(test_trace) - # runners_token can't normally be set to nil - allow(build.project).to receive(:runners_token).and_return(nil) - - expect(build.trace).to eq(test_trace) - end - - context 'when build does not have trace' do - it 'is is empty' do - expect(build.trace).to be_nil - end - end - - context 'when trace contains text' do - let(:text) { 'example output' } - before do - build.trace = text - end - - it { expect(build.trace).to eq(text) } - end - - context 'when trace hides runners token' do - let(:token) { 'my_secret_token' } - - before do - build.update(trace: token) - build.project.update(runners_token: token) - end - - it { expect(build.trace).not_to include(token) } - it { expect(build.raw_trace).to include(token) } - end - - context 'when build.trace hides build token' do - let(:token) { 'my_secret_token' } - - before do - build.update(trace: token) - build.update(token: token) - end - - it { expect(build.trace).not_to include(token) } - it { expect(build.raw_trace).to include(token) } - end - end - describe '#has_expiring_artifacts?' do context 'when artifacts have expiration date set' do before { build.update(artifacts_expire_at: 1.day.from_now) } @@ -1147,66 +1095,6 @@ describe Ci::Build, :models do end end - describe '#has_trace_file?' do - context 'when there is no trace' do - it { expect(build.has_trace_file?).to be_falsey } - it { expect(build.trace).to be_nil } - end - - context 'when there is a trace' do - context 'when trace is stored in file' do - let(:build_with_trace) { create(:ci_build, :trace) } - - it { expect(build_with_trace.has_trace_file?).to be_truthy } - it { expect(build_with_trace.trace).to eq('BUILD TRACE') } - end - - context 'when trace is stored in old file' do - before do - allow(build.project).to receive(:ci_id).and_return(999) - allow(File).to receive(:exist?).with(build.path_to_trace).and_return(false) - allow(File).to receive(:exist?).with(build.old_path_to_trace).and_return(true) - allow(File).to receive(:read).with(build.old_path_to_trace).and_return(test_trace) - end - - it { expect(build.has_trace_file?).to be_truthy } - it { expect(build.trace).to eq(test_trace) } - end - - context 'when trace is stored in DB' do - before do - allow(build.project).to receive(:ci_id).and_return(nil) - allow(build).to receive(:read_attribute).with(:trace).and_return(test_trace) - allow(File).to receive(:exist?).with(build.path_to_trace).and_return(false) - allow(File).to receive(:exist?).with(build.old_path_to_trace).and_return(false) - end - - it { expect(build.has_trace_file?).to be_falsey } - it { expect(build.trace).to eq(test_trace) } - end - end - end - - describe '#trace_file_path' do - context 'when trace is stored in file' do - before do - allow(build).to receive(:has_trace_file?).and_return(true) - allow(build).to receive(:has_old_trace_file?).and_return(false) - end - - it { expect(build.trace_file_path).to eq(build.path_to_trace) } - end - - context 'when trace is stored in old file' do - before do - allow(build).to receive(:has_trace_file?).and_return(true) - allow(build).to receive(:has_old_trace_file?).and_return(true) - end - - it { expect(build.trace_file_path).to eq(build.old_path_to_trace) } - end - end - describe '#update_project_statistics' do let!(:build) { create(:ci_build, artifacts_size: 23) } @@ -1460,7 +1348,7 @@ describe Ci::Build, :models do { key: 'CI_REGISTRY', value: 'registry.example.com', public: true } end let(:ci_registry_image) do - { key: 'CI_REGISTRY_IMAGE', value: project.container_registry_repository_url, public: true } + { key: 'CI_REGISTRY_IMAGE', value: project.container_registry_url, public: true } end context 'and is disabled for project' do diff --git a/spec/models/ci/trigger_schedule_spec.rb b/spec/models/ci/trigger_schedule_spec.rb new file mode 100644 index 00000000000..75d21541cee --- /dev/null +++ b/spec/models/ci/trigger_schedule_spec.rb @@ -0,0 +1,76 @@ +require 'spec_helper' + +describe Ci::TriggerSchedule, models: true do + it { is_expected.to belong_to(:project) } + it { is_expected.to belong_to(:trigger) } + it { is_expected.to respond_to(:ref) } + + describe '#set_next_run_at' do + context 'when creates new TriggerSchedule' do + before do + trigger_schedule = create(:ci_trigger_schedule, :nightly) + @expected_next_run_at = Gitlab::Ci::CronParser.new(trigger_schedule.cron, trigger_schedule.cron_timezone) + .next_time_from(Time.now) + end + + it 'updates next_run_at automatically' do + expect(Ci::TriggerSchedule.last.next_run_at).to eq(@expected_next_run_at) + end + end + + context 'when updates cron of exsisted TriggerSchedule' do + before do + trigger_schedule = create(:ci_trigger_schedule, :nightly) + new_cron = '0 0 1 1 *' + trigger_schedule.update!(cron: new_cron) # Subject + @expected_next_run_at = Gitlab::Ci::CronParser.new(new_cron, trigger_schedule.cron_timezone) + .next_time_from(Time.now) + end + + it 'updates next_run_at automatically' do + expect(Ci::TriggerSchedule.last.next_run_at).to eq(@expected_next_run_at) + end + end + end + + describe '#schedule_next_run!' do + context 'when reschedules after 10 days from now' do + before do + trigger_schedule = create(:ci_trigger_schedule, :nightly) + time_future = Time.now + 10.days + allow(Time).to receive(:now).and_return(time_future) + trigger_schedule.schedule_next_run! # Subject + @expected_next_run_at = Gitlab::Ci::CronParser.new(trigger_schedule.cron, trigger_schedule.cron_timezone) + .next_time_from(time_future) + end + + it 'points to proper next_run_at' do + expect(Ci::TriggerSchedule.last.next_run_at).to eq(@expected_next_run_at) + end + end + + context 'when cron is invalid' do + before do + trigger_schedule = create(:ci_trigger_schedule, :nightly) + trigger_schedule.cron = 'Invalid-cron' + trigger_schedule.schedule_next_run! # Subject + end + + it 'sets nil to next_run_at' do + expect(Ci::TriggerSchedule.last.next_run_at).to be_nil + end + end + + context 'when cron_timezone is invalid' do + before do + trigger_schedule = create(:ci_trigger_schedule, :nightly) + trigger_schedule.cron_timezone = 'Invalid-cron_timezone' + trigger_schedule.schedule_next_run! # Subject + end + + it 'sets nil to next_run_at' do + expect(Ci::TriggerSchedule.last.next_run_at).to be_nil + end + end + end +end diff --git a/spec/models/ci/trigger_spec.rb b/spec/models/ci/trigger_spec.rb index 1bcb673cb16..42170de0180 100644 --- a/spec/models/ci/trigger_spec.rb +++ b/spec/models/ci/trigger_spec.rb @@ -7,6 +7,7 @@ describe Ci::Trigger, models: true do it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:owner) } it { is_expected.to have_many(:trigger_requests) } + it { is_expected.to have_one(:trigger_schedule) } end describe 'before_validation' do diff --git a/spec/models/concerns/discussion_on_diff_spec.rb b/spec/models/concerns/discussion_on_diff_spec.rb new file mode 100644 index 00000000000..0002a00770f --- /dev/null +++ b/spec/models/concerns/discussion_on_diff_spec.rb @@ -0,0 +1,24 @@ +require 'spec_helper' + +describe DiffDiscussion, DiscussionOnDiff, model: true do + subject { create(:diff_note_on_merge_request).to_discussion } + + describe "#truncated_diff_lines" do + let(:truncated_lines) { subject.truncated_diff_lines } + + context "when diff is greater than allowed number of truncated diff lines " do + it "returns fewer lines" do + expect(subject.diff_lines.count).to be > described_class::NUMBER_OF_TRUNCATED_DIFF_LINES + + expect(truncated_lines.count).to be <= described_class::NUMBER_OF_TRUNCATED_DIFF_LINES + end + end + + context "when some diff lines are meta" do + it "returns no meta lines" do + expect(subject.diff_lines).to include(be_meta) + expect(truncated_lines).not_to include(be_meta) + end + end + end +end diff --git a/spec/models/concerns/noteable_spec.rb b/spec/models/concerns/noteable_spec.rb new file mode 100644 index 00000000000..92cc8859a8c --- /dev/null +++ b/spec/models/concerns/noteable_spec.rb @@ -0,0 +1,261 @@ +require 'spec_helper' + +describe MergeRequest, Noteable, model: true do + let!(:active_diff_note1) { create(:diff_note_on_merge_request) } + let(:project) { active_diff_note1.project } + subject { active_diff_note1.noteable } + let!(:active_diff_note2) { create(:diff_note_on_merge_request, project: project, noteable: subject, in_reply_to: active_diff_note1) } + let!(:active_diff_note3) { create(:diff_note_on_merge_request, project: project, noteable: subject, position: active_position2) } + let!(:outdated_diff_note1) { create(:diff_note_on_merge_request, project: project, noteable: subject, position: outdated_position) } + let!(:outdated_diff_note2) { create(:diff_note_on_merge_request, project: project, noteable: subject, in_reply_to: outdated_diff_note1) } + let!(:discussion_note1) { create(:discussion_note_on_merge_request, project: project, noteable: subject) } + let!(:discussion_note2) { create(:discussion_note_on_merge_request, in_reply_to: discussion_note1) } + let!(:commit_diff_note1) { create(:diff_note_on_commit, project: project) } + let!(:commit_diff_note2) { create(:diff_note_on_commit, project: project, in_reply_to: commit_diff_note1) } + let!(:commit_note1) { create(:note_on_commit, project: project) } + let!(:commit_note2) { create(:note_on_commit, project: project) } + let!(:commit_discussion_note1) { create(:discussion_note_on_commit, project: project) } + let!(:commit_discussion_note2) { create(:discussion_note_on_commit, in_reply_to: commit_discussion_note1) } + let!(:commit_discussion_note3) { create(:discussion_note_on_commit, project: project) } + let!(:note1) { create(:note, project: project, noteable: subject) } + let!(:note2) { create(:note, project: project, noteable: subject) } + + let(:active_position2) do + Gitlab::Diff::Position.new( + old_path: "files/ruby/popen.rb", + new_path: "files/ruby/popen.rb", + old_line: 16, + new_line: 22, + diff_refs: subject.diff_refs + ) + end + + let(:outdated_position) do + Gitlab::Diff::Position.new( + old_path: "files/ruby/popen.rb", + new_path: "files/ruby/popen.rb", + old_line: nil, + new_line: 9, + diff_refs: project.commit("874797c3a73b60d2187ed6e2fcabd289ff75171e").diff_refs + ) + end + + describe '#discussions' do + let(:discussions) { subject.discussions } + + it 'includes discussions for diff notes, commit diff notes, commit notes, and regular notes' do + expect(discussions).to eq([ + DiffDiscussion.new([active_diff_note1, active_diff_note2], subject), + DiffDiscussion.new([active_diff_note3], subject), + DiffDiscussion.new([outdated_diff_note1, outdated_diff_note2], subject), + Discussion.new([discussion_note1, discussion_note2], subject), + DiffDiscussion.new([commit_diff_note1, commit_diff_note2], subject), + OutOfContextDiscussion.new([commit_note1, commit_note2], subject), + Discussion.new([commit_discussion_note1, commit_discussion_note2], subject), + Discussion.new([commit_discussion_note3], subject), + IndividualNoteDiscussion.new([note1], subject), + IndividualNoteDiscussion.new([note2], subject) + ]) + end + end + + describe '#grouped_diff_discussions' do + let(:grouped_diff_discussions) { subject.grouped_diff_discussions } + + it "includes active discussions" do + discussions = grouped_diff_discussions.values.flatten + + expect(discussions.count).to eq(2) + expect(discussions.map(&:id)).to eq([active_diff_note1.discussion_id, active_diff_note3.discussion_id]) + expect(discussions.all?(&:active?)).to be true + + expect(discussions.first.notes).to eq([active_diff_note1, active_diff_note2]) + expect(discussions.last.notes).to eq([active_diff_note3]) + end + + it "doesn't include outdated discussions" do + expect(grouped_diff_discussions.values.flatten.map(&:id)).not_to include(outdated_diff_note1.discussion_id) + end + + it "groups the discussions by line code" do + expect(grouped_diff_discussions[active_diff_note1.line_code].first.id).to eq(active_diff_note1.discussion_id) + expect(grouped_diff_discussions[active_diff_note3.line_code].first.id).to eq(active_diff_note3.discussion_id) + end + end + + context "discussion status" do + let(:first_discussion) { build_stubbed(:discussion_note_on_merge_request, noteable: subject, project: project).to_discussion } + let(:second_discussion) { build_stubbed(:discussion_note_on_merge_request, noteable: subject, project: project).to_discussion } + let(:third_discussion) { build_stubbed(:discussion_note_on_merge_request, noteable: subject, project: project).to_discussion } + + before do + allow(subject).to receive(:resolvable_discussions).and_return([first_discussion, second_discussion, third_discussion]) + end + + describe "#discussions_resolvable?" do + context "when all discussions are unresolvable" do + before do + allow(first_discussion).to receive(:resolvable?).and_return(false) + allow(second_discussion).to receive(:resolvable?).and_return(false) + allow(third_discussion).to receive(:resolvable?).and_return(false) + end + + it "returns false" do + expect(subject.discussions_resolvable?).to be false + end + end + + context "when some discussions are unresolvable and some discussions are resolvable" do + before do + allow(first_discussion).to receive(:resolvable?).and_return(true) + allow(second_discussion).to receive(:resolvable?).and_return(false) + allow(third_discussion).to receive(:resolvable?).and_return(true) + end + + it "returns true" do + expect(subject.discussions_resolvable?).to be true + end + end + + context "when all discussions are resolvable" do + before do + allow(first_discussion).to receive(:resolvable?).and_return(true) + allow(second_discussion).to receive(:resolvable?).and_return(true) + allow(third_discussion).to receive(:resolvable?).and_return(true) + end + + it "returns true" do + expect(subject.discussions_resolvable?).to be true + end + end + end + + describe "#discussions_resolved?" do + context "when discussions are not resolvable" do + before do + allow(subject).to receive(:discussions_resolvable?).and_return(false) + end + + it "returns false" do + expect(subject.discussions_resolved?).to be false + end + end + + context "when discussions are resolvable" do + before do + allow(subject).to receive(:discussions_resolvable?).and_return(true) + + allow(first_discussion).to receive(:resolvable?).and_return(true) + allow(second_discussion).to receive(:resolvable?).and_return(false) + allow(third_discussion).to receive(:resolvable?).and_return(true) + end + + context "when all resolvable discussions are resolved" do + before do + allow(first_discussion).to receive(:resolved?).and_return(true) + allow(third_discussion).to receive(:resolved?).and_return(true) + end + + it "returns true" do + expect(subject.discussions_resolved?).to be true + end + end + + context "when some resolvable discussions are not resolved" do + before do + allow(first_discussion).to receive(:resolved?).and_return(true) + allow(third_discussion).to receive(:resolved?).and_return(false) + end + + it "returns false" do + expect(subject.discussions_resolved?).to be false + end + end + end + end + + describe "#discussions_to_be_resolved?" do + context "when discussions are not resolvable" do + before do + allow(subject).to receive(:discussions_resolvable?).and_return(false) + end + + it "returns false" do + expect(subject.discussions_to_be_resolved?).to be false + end + end + + context "when discussions are resolvable" do + before do + allow(subject).to receive(:discussions_resolvable?).and_return(true) + + allow(first_discussion).to receive(:resolvable?).and_return(true) + allow(second_discussion).to receive(:resolvable?).and_return(false) + allow(third_discussion).to receive(:resolvable?).and_return(true) + end + + context "when all resolvable discussions are resolved" do + before do + allow(first_discussion).to receive(:resolved?).and_return(true) + allow(third_discussion).to receive(:resolved?).and_return(true) + end + + it "returns false" do + expect(subject.discussions_to_be_resolved?).to be false + end + end + + context "when some resolvable discussions are not resolved" do + before do + allow(first_discussion).to receive(:resolved?).and_return(true) + allow(third_discussion).to receive(:resolved?).and_return(false) + end + + it "returns true" do + expect(subject.discussions_to_be_resolved?).to be true + end + end + end + end + + describe "#discussions_to_be_resolved" do + before do + allow(first_discussion).to receive(:to_be_resolved?).and_return(true) + allow(second_discussion).to receive(:to_be_resolved?).and_return(false) + allow(third_discussion).to receive(:to_be_resolved?).and_return(false) + end + + it 'includes only discussions that need to be resolved' do + expect(subject.discussions_to_be_resolved).to eq([first_discussion]) + end + end + + describe '#discussions_can_be_resolved_by?' do + let(:user) { build(:user) } + + context 'all discussions can be resolved by the user' do + before do + allow(first_discussion).to receive(:can_resolve?).with(user).and_return(true) + allow(second_discussion).to receive(:can_resolve?).with(user).and_return(true) + allow(third_discussion).to receive(:can_resolve?).with(user).and_return(true) + end + + it 'allows a user to resolve the discussions' do + expect(subject.discussions_can_be_resolved_by?(user)).to be(true) + end + end + + context 'one discussion cannot be resolved by the user' do + before do + allow(first_discussion).to receive(:can_resolve?).with(user).and_return(true) + allow(second_discussion).to receive(:can_resolve?).with(user).and_return(true) + allow(third_discussion).to receive(:can_resolve?).with(user).and_return(false) + end + + it 'allows a user to resolve the discussions' do + expect(subject.discussions_can_be_resolved_by?(user)).to be(false) + end + end + end + end +end diff --git a/spec/models/concerns/resolvable_discussion_spec.rb b/spec/models/concerns/resolvable_discussion_spec.rb new file mode 100644 index 00000000000..18327fe262d --- /dev/null +++ b/spec/models/concerns/resolvable_discussion_spec.rb @@ -0,0 +1,548 @@ +require 'spec_helper' + +describe Discussion, ResolvableDiscussion, models: true do + subject { described_class.new([first_note, second_note, third_note]) } + + let(:first_note) { create(:discussion_note_on_merge_request) } + let(:merge_request) { first_note.noteable } + let(:project) { first_note.project } + let(:second_note) { create(:discussion_note_on_merge_request, noteable: merge_request, project: project, in_reply_to: first_note) } + let(:third_note) { create(:discussion_note_on_merge_request, noteable: merge_request, project: project) } + + describe "#resolvable?" do + context "when potentially resolvable" do + before do + allow(subject).to receive(:potentially_resolvable?).and_return(true) + end + + context "when all notes are unresolvable" do + before do + allow(first_note).to receive(:resolvable?).and_return(false) + allow(second_note).to receive(:resolvable?).and_return(false) + allow(third_note).to receive(:resolvable?).and_return(false) + end + + it "returns false" do + expect(subject.resolvable?).to be false + end + end + + context "when some notes are unresolvable and some notes are resolvable" do + before do + allow(first_note).to receive(:resolvable?).and_return(true) + allow(second_note).to receive(:resolvable?).and_return(false) + allow(third_note).to receive(:resolvable?).and_return(true) + end + + it "returns true" do + expect(subject.resolvable?).to be true + end + end + + context "when all notes are resolvable" do + before do + allow(first_note).to receive(:resolvable?).and_return(true) + allow(second_note).to receive(:resolvable?).and_return(true) + allow(third_note).to receive(:resolvable?).and_return(true) + end + + it "returns true" do + expect(subject.resolvable?).to be true + end + end + end + + context "when not potentially resolvable" do + before do + allow(subject).to receive(:potentially_resolvable?).and_return(false) + end + + it "returns false" do + expect(subject.resolvable?).to be false + end + end + end + + describe "#resolved?" do + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + it "returns false" do + expect(subject.resolved?).to be false + end + end + + context "when resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(true) + + allow(first_note).to receive(:resolvable?).and_return(true) + allow(second_note).to receive(:resolvable?).and_return(false) + allow(third_note).to receive(:resolvable?).and_return(true) + end + + context "when all resolvable notes are resolved" do + before do + allow(first_note).to receive(:resolved?).and_return(true) + allow(third_note).to receive(:resolved?).and_return(true) + end + + it "returns true" do + expect(subject.resolved?).to be true + end + end + + context "when some resolvable notes are not resolved" do + before do + allow(first_note).to receive(:resolved?).and_return(true) + allow(third_note).to receive(:resolved?).and_return(false) + end + + it "returns false" do + expect(subject.resolved?).to be false + end + end + end + end + + describe "#to_be_resolved?" do + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + it "returns false" do + expect(subject.to_be_resolved?).to be false + end + end + + context "when resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(true) + + allow(first_note).to receive(:resolvable?).and_return(true) + allow(second_note).to receive(:resolvable?).and_return(false) + allow(third_note).to receive(:resolvable?).and_return(true) + end + + context "when all resolvable notes are resolved" do + before do + allow(first_note).to receive(:resolved?).and_return(true) + allow(third_note).to receive(:resolved?).and_return(true) + end + + it "returns false" do + expect(subject.to_be_resolved?).to be false + end + end + + context "when some resolvable notes are not resolved" do + before do + allow(first_note).to receive(:resolved?).and_return(true) + allow(third_note).to receive(:resolved?).and_return(false) + end + + it "returns true" do + expect(subject.to_be_resolved?).to be true + end + end + end + end + + describe "#can_resolve?" do + let(:current_user) { create(:user) } + + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + it "returns false" do + expect(subject.can_resolve?(current_user)).to be false + end + end + + context "when resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(true) + end + + context "when not signed in" do + let(:current_user) { nil } + + it "returns false" do + expect(subject.can_resolve?(current_user)).to be false + end + end + + context "when signed in" do + context "when the signed in user is the noteable author" do + before do + subject.noteable.author = current_user + end + + it "returns true" do + expect(subject.can_resolve?(current_user)).to be true + end + end + + context "when the signed in user can push to the project" do + before do + subject.project.team << [current_user, :master] + end + + it "returns true" do + expect(subject.can_resolve?(current_user)).to be true + end + end + + context "when the signed in user is a random user" do + it "returns false" do + expect(subject.can_resolve?(current_user)).to be false + end + end + end + end + end + + describe "#resolve!" do + let(:current_user) { create(:user) } + + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + it "returns nil" do + expect(subject.resolve!(current_user)).to be_nil + end + + it "doesn't set resolved_at" do + subject.resolve!(current_user) + + expect(subject.resolved_at).to be_nil + end + + it "doesn't set resolved_by" do + subject.resolve!(current_user) + + expect(subject.resolved_by).to be_nil + end + + it "doesn't mark as resolved" do + subject.resolve!(current_user) + + expect(subject.resolved?).to be false + end + end + + context "when resolvable" do + let(:user) { create(:user) } + let(:second_note) { create(:diff_note_on_commit) } # unresolvable + + before do + allow(subject).to receive(:resolvable?).and_return(true) + end + + context "when all resolvable notes are resolved" do + before do + first_note.resolve!(user) + third_note.resolve!(user) + + first_note.reload + third_note.reload + end + + it "doesn't change resolved_at on the resolved notes" do + expect(first_note.resolved_at).not_to be_nil + expect(third_note.resolved_at).not_to be_nil + + expect { subject.resolve!(current_user) }.not_to change { first_note.resolved_at } + expect { subject.resolve!(current_user) }.not_to change { third_note.resolved_at } + end + + it "doesn't change resolved_by on the resolved notes" do + expect(first_note.resolved_by).to eq(user) + expect(third_note.resolved_by).to eq(user) + + expect { subject.resolve!(current_user) }.not_to change { first_note.resolved_by } + expect { subject.resolve!(current_user) }.not_to change { third_note.resolved_by } + end + + it "doesn't change the resolved state on the resolved notes" do + expect(first_note.resolved?).to be true + expect(third_note.resolved?).to be true + + expect { subject.resolve!(current_user) }.not_to change { first_note.resolved? } + expect { subject.resolve!(current_user) }.not_to change { third_note.resolved? } + end + + it "doesn't change resolved_at" do + expect(subject.resolved_at).not_to be_nil + + expect { subject.resolve!(current_user) }.not_to change { subject.resolved_at } + end + + it "doesn't change resolved_by" do + expect(subject.resolved_by).to eq(user) + + expect { subject.resolve!(current_user) }.not_to change { subject.resolved_by } + end + + it "doesn't change resolved state" do + expect(subject.resolved?).to be true + + expect { subject.resolve!(current_user) }.not_to change { subject.resolved? } + end + end + + context "when some resolvable notes are resolved" do + before do + first_note.resolve!(user) + end + + it "doesn't change resolved_at on the resolved note" do + expect(first_note.resolved_at).not_to be_nil + + expect { subject.resolve!(current_user) }. + not_to change { first_note.reload.resolved_at } + end + + it "doesn't change resolved_by on the resolved note" do + expect(first_note.resolved_by).to eq(user) + + expect { subject.resolve!(current_user) }. + not_to change { first_note.reload && first_note.resolved_by } + end + + it "doesn't change the resolved state on the resolved note" do + expect(first_note.resolved?).to be true + + expect { subject.resolve!(current_user) }. + not_to change { first_note.reload && first_note.resolved? } + end + + it "sets resolved_at on the unresolved note" do + subject.resolve!(current_user) + third_note.reload + + expect(third_note.resolved_at).not_to be_nil + end + + it "sets resolved_by on the unresolved note" do + subject.resolve!(current_user) + third_note.reload + + expect(third_note.resolved_by).to eq(current_user) + end + + it "marks the unresolved note as resolved" do + subject.resolve!(current_user) + third_note.reload + + expect(third_note.resolved?).to be true + end + + it "sets resolved_at" do + subject.resolve!(current_user) + + expect(subject.resolved_at).not_to be_nil + end + + it "sets resolved_by" do + subject.resolve!(current_user) + + expect(subject.resolved_by).to eq(current_user) + end + + it "marks as resolved" do + subject.resolve!(current_user) + + expect(subject.resolved?).to be true + end + end + + context "when no resolvable notes are resolved" do + it "sets resolved_at on the unresolved notes" do + subject.resolve!(current_user) + first_note.reload + third_note.reload + + expect(first_note.resolved_at).not_to be_nil + expect(third_note.resolved_at).not_to be_nil + end + + it "sets resolved_by on the unresolved notes" do + subject.resolve!(current_user) + first_note.reload + third_note.reload + + expect(first_note.resolved_by).to eq(current_user) + expect(third_note.resolved_by).to eq(current_user) + end + + it "marks the unresolved notes as resolved" do + subject.resolve!(current_user) + first_note.reload + third_note.reload + + expect(first_note.resolved?).to be true + expect(third_note.resolved?).to be true + end + + it "sets resolved_at" do + subject.resolve!(current_user) + first_note.reload + third_note.reload + + expect(subject.resolved_at).not_to be_nil + end + + it "sets resolved_by" do + subject.resolve!(current_user) + first_note.reload + third_note.reload + + expect(subject.resolved_by).to eq(current_user) + end + + it "marks as resolved" do + subject.resolve!(current_user) + first_note.reload + third_note.reload + + expect(subject.resolved?).to be true + end + end + end + end + + describe "#unresolve!" do + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + it "returns nil" do + expect(subject.unresolve!).to be_nil + end + end + + context "when resolvable" do + let(:user) { create(:user) } + + before do + allow(subject).to receive(:resolvable?).and_return(true) + + allow(first_note).to receive(:resolvable?).and_return(true) + allow(second_note).to receive(:resolvable?).and_return(false) + allow(third_note).to receive(:resolvable?).and_return(true) + end + + context "when all resolvable notes are resolved" do + before do + first_note.resolve!(user) + third_note.resolve!(user) + end + + it "unsets resolved_at on the resolved notes" do + subject.unresolve! + first_note.reload + third_note.reload + + expect(first_note.resolved_at).to be_nil + expect(third_note.resolved_at).to be_nil + end + + it "unsets resolved_by on the resolved notes" do + subject.unresolve! + first_note.reload + third_note.reload + + expect(first_note.resolved_by).to be_nil + expect(third_note.resolved_by).to be_nil + end + + it "unmarks the resolved notes as resolved" do + subject.unresolve! + first_note.reload + third_note.reload + + expect(first_note.resolved?).to be false + expect(third_note.resolved?).to be false + end + + it "unsets resolved_at" do + subject.unresolve! + first_note.reload + third_note.reload + + expect(subject.resolved_at).to be_nil + end + + it "unsets resolved_by" do + subject.unresolve! + first_note.reload + third_note.reload + + expect(subject.resolved_by).to be_nil + end + + it "unmarks as resolved" do + subject.unresolve! + + expect(subject.resolved?).to be false + end + end + + context "when some resolvable notes are resolved" do + before do + first_note.resolve!(user) + end + + it "unsets resolved_at on the resolved note" do + subject.unresolve! + + expect(subject.first_note.resolved_at).to be_nil + end + + it "unsets resolved_by on the resolved note" do + subject.unresolve! + + expect(subject.first_note.resolved_by).to be_nil + end + + it "unmarks the resolved note as resolved" do + subject.unresolve! + + expect(subject.first_note.resolved?).to be false + end + end + end + end + + describe "#first_note_to_resolve" do + it "returns the first note that still needs to be resolved" do + allow(first_note).to receive(:to_be_resolved?).and_return(false) + allow(second_note).to receive(:to_be_resolved?).and_return(true) + + expect(subject.first_note_to_resolve).to eq(second_note) + end + end + + describe "#last_resolved_note" do + let(:current_user) { create(:user) } + + before do + first_note.resolve!(current_user) + third_note.resolve!(current_user) + second_note.resolve!(current_user) + end + + it "returns the last note that was resolved" do + expect(subject.last_resolved_note).to eq(second_note) + end + end +end diff --git a/spec/models/concerns/resolvable_note_spec.rb b/spec/models/concerns/resolvable_note_spec.rb new file mode 100644 index 00000000000..1503ccdff11 --- /dev/null +++ b/spec/models/concerns/resolvable_note_spec.rb @@ -0,0 +1,329 @@ +require 'spec_helper' + +describe Note, ResolvableNote, models: true do + let(:project) { create(:project) } + let(:merge_request) { create(:merge_request, source_project: project) } + subject { create(:discussion_note_on_merge_request, noteable: merge_request, project: project) } + + context 'resolvability scopes' do + let!(:note1) { create(:note, project: project) } + let!(:note2) { create(:diff_note_on_commit, project: project) } + let!(:note3) { create(:diff_note_on_merge_request, :resolved, noteable: merge_request, project: project) } + let!(:note4) { create(:discussion_note_on_merge_request, noteable: merge_request, project: project) } + let!(:note5) { create(:discussion_note_on_issue, project: project) } + let!(:note6) { create(:discussion_note_on_merge_request, :system, noteable: merge_request, project: project) } + + describe '.potentially_resolvable' do + it 'includes diff and discussion notes on merge requests' do + expect(Note.potentially_resolvable).to match_array([note3, note4, note6]) + end + end + + describe '.resolvable' do + it 'includes non-system diff and discussion notes on merge requests' do + expect(Note.resolvable).to match_array([note3, note4]) + end + end + + describe '.resolved' do + it 'includes resolved non-system diff and discussion notes on merge requests' do + expect(Note.resolved).to match_array([note3]) + end + end + + describe '.unresolved' do + it 'includes non-resolved non-system diff and discussion notes on merge requests' do + expect(Note.unresolved).to match_array([note4]) + end + end + end + + describe ".resolve!" do + let(:current_user) { create(:user) } + let!(:commit_note) { create(:diff_note_on_commit, project: project) } + let!(:resolved_note) { create(:discussion_note_on_merge_request, :resolved, noteable: merge_request, project: project) } + let!(:unresolved_note) { create(:discussion_note_on_merge_request, noteable: merge_request, project: project) } + + before do + described_class.resolve!(current_user) + + commit_note.reload + resolved_note.reload + unresolved_note.reload + end + + it 'resolves only the resolvable, not yet resolved notes' do + expect(commit_note.resolved_at).to be_nil + expect(resolved_note.resolved_by).not_to eq(current_user) + expect(unresolved_note.resolved_at).not_to be_nil + expect(unresolved_note.resolved_by).to eq(current_user) + end + end + + describe ".unresolve!" do + let!(:resolved_note) { create(:discussion_note_on_merge_request, :resolved, noteable: merge_request, project: project) } + + before do + described_class.unresolve! + + resolved_note.reload + end + + it 'unresolves the resolved notes' do + expect(resolved_note.resolved_by).to be_nil + expect(resolved_note.resolved_at).to be_nil + end + end + + describe '#resolvable?' do + context "when potentially resolvable" do + before do + allow(subject).to receive(:potentially_resolvable?).and_return(true) + end + + context "when a system note" do + before do + subject.system = true + end + + it "returns false" do + expect(subject.resolvable?).to be false + end + end + + context "when a regular note" do + it "returns true" do + expect(subject.resolvable?).to be true + end + end + end + + context "when not potentially resolvable" do + before do + allow(subject).to receive(:potentially_resolvable?).and_return(false) + end + + it "returns false" do + expect(subject.resolvable?).to be false + end + end + end + + describe "#to_be_resolved?" do + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + it "returns false" do + expect(subject.to_be_resolved?).to be false + end + end + + context "when resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(true) + end + + context "when resolved" do + before do + allow(subject).to receive(:resolved?).and_return(true) + end + + it "returns false" do + expect(subject.to_be_resolved?).to be false + end + end + + context "when not resolved" do + before do + allow(subject).to receive(:resolved?).and_return(false) + end + + it "returns true" do + expect(subject.to_be_resolved?).to be true + end + end + end + end + + describe "#resolved?" do + let(:current_user) { create(:user) } + + context 'when not resolvable' do + before do + subject.resolve!(current_user) + + allow(subject).to receive(:resolvable?).and_return(false) + end + + it 'returns false' do + expect(subject.resolved?).to be_falsey + end + end + + context 'when resolvable' do + context 'when the note has been resolved' do + before do + subject.resolve!(current_user) + end + + it 'returns true' do + expect(subject.resolved?).to be_truthy + end + end + + context 'when the note has not been resolved' do + it 'returns false' do + expect(subject.resolved?).to be_falsey + end + end + end + end + + describe "#resolve!" do + let(:current_user) { create(:user) } + + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + it "returns nil" do + expect(subject.resolve!(current_user)).to be_nil + end + + it "doesn't set resolved_at" do + subject.resolve!(current_user) + + expect(subject.resolved_at).to be_nil + end + + it "doesn't set resolved_by" do + subject.resolve!(current_user) + + expect(subject.resolved_by).to be_nil + end + + it "doesn't mark as resolved" do + subject.resolve!(current_user) + + expect(subject.resolved?).to be false + end + end + + context "when resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(true) + end + + context "when already resolved" do + let(:user) { create(:user) } + + before do + subject.resolve!(user) + end + + it "returns nil" do + expect(subject.resolve!(current_user)).to be_nil + end + + it "doesn't change resolved_at" do + expect(subject.resolved_at).not_to be_nil + + expect { subject.resolve!(current_user) }.not_to change { subject.resolved_at } + end + + it "doesn't change resolved_by" do + expect(subject.resolved_by).to eq(user) + + expect { subject.resolve!(current_user) }.not_to change { subject.resolved_by } + end + + it "doesn't change resolved status" do + expect(subject.resolved?).to be true + + expect { subject.resolve!(current_user) }.not_to change { subject.resolved? } + end + end + + context "when not yet resolved" do + it "returns true" do + expect(subject.resolve!(current_user)).to be true + end + + it "sets resolved_at" do + subject.resolve!(current_user) + + expect(subject.resolved_at).not_to be_nil + end + + it "sets resolved_by" do + subject.resolve!(current_user) + + expect(subject.resolved_by).to eq(current_user) + end + + it "marks as resolved" do + subject.resolve!(current_user) + + expect(subject.resolved?).to be true + end + end + end + end + + describe "#unresolve!" do + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + it "returns nil" do + expect(subject.unresolve!).to be_nil + end + end + + context "when resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(true) + end + + context "when resolved" do + let(:user) { create(:user) } + + before do + subject.resolve!(user) + end + + it "returns true" do + expect(subject.unresolve!).to be true + end + + it "unsets resolved_at" do + subject.unresolve! + + expect(subject.resolved_at).to be_nil + end + + it "unsets resolved_by" do + subject.unresolve! + + expect(subject.resolved_by).to be_nil + end + + it "unmarks as resolved" do + subject.unresolve! + + expect(subject.resolved?).to be false + end + end + + context "when not resolved" do + it "returns nil" do + expect(subject.unresolve!).to be_nil + end + end + end + end +end diff --git a/spec/models/concerns/routable_spec.rb b/spec/models/concerns/routable_spec.rb index 677e60e1282..f191605dbdb 100644 --- a/spec/models/concerns/routable_spec.rb +++ b/spec/models/concerns/routable_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe Group, 'Routable' do - let!(:group) { create(:group) } + let!(:group) { create(:group, name: 'foo') } describe 'Validations' do it { is_expected.to validate_presence_of(:route) } @@ -81,6 +81,113 @@ describe Group, 'Routable' do it { is_expected.to eq([nested_group]) } end + describe '.member_self_and_descendants' do + let!(:user) { create(:user) } + let!(:nested_group) { create(:group, parent: group) } + + before { group.add_owner(user) } + subject { described_class.member_self_and_descendants(user.id) } + + it { is_expected.to match_array [group, nested_group] } + end + + describe '.member_hierarchy' do + # foo/bar would also match foo/barbaz instead of just foo/bar and foo/bar/baz + let!(:user) { create(:user) } + + # group + # _______ (foo) _______ + # | | + # | | + # nested_group_1 nested_group_2 + # (bar) (barbaz) + # | | + # | | + # nested_group_1_1 nested_group_2_1 + # (baz) (baz) + # + let!(:nested_group_1) { create :group, parent: group, name: 'bar' } + let!(:nested_group_1_1) { create :group, parent: nested_group_1, name: 'baz' } + let!(:nested_group_2) { create :group, parent: group, name: 'barbaz' } + let!(:nested_group_2_1) { create :group, parent: nested_group_2, name: 'baz' } + + context 'user is not a member of any group' do + subject { described_class.member_hierarchy(user.id) } + + it 'returns an empty array' do + is_expected.to eq [] + end + end + + context 'user is member of all groups' do + before do + group.add_owner(user) + nested_group_1.add_owner(user) + nested_group_1_1.add_owner(user) + nested_group_2.add_owner(user) + nested_group_2_1.add_owner(user) + end + subject { described_class.member_hierarchy(user.id) } + + it 'returns all groups' do + is_expected.to match_array [ + group, + nested_group_1, nested_group_1_1, + nested_group_2, nested_group_2_1 + ] + end + end + + context 'user is member of the top group' do + before { group.add_owner(user) } + subject { described_class.member_hierarchy(user.id) } + + it 'returns all groups' do + is_expected.to match_array [ + group, + nested_group_1, nested_group_1_1, + nested_group_2, nested_group_2_1 + ] + end + end + + context 'user is member of the first child (internal node), branch 1' do + before { nested_group_1.add_owner(user) } + subject { described_class.member_hierarchy(user.id) } + + it 'returns the groups in the hierarchy' do + is_expected.to match_array [ + group, + nested_group_1, nested_group_1_1 + ] + end + end + + context 'user is member of the first child (internal node), branch 2' do + before { nested_group_2.add_owner(user) } + subject { described_class.member_hierarchy(user.id) } + + it 'returns the groups in the hierarchy' do + is_expected.to match_array [ + group, + nested_group_2, nested_group_2_1 + ] + end + end + + context 'user is member of the last child (leaf node)' do + before { nested_group_1_1.add_owner(user) } + subject { described_class.member_hierarchy(user.id) } + + it 'returns the groups in the hierarchy' do + is_expected.to match_array [ + group, + nested_group_1, nested_group_1_1 + ] + end + end + end + describe '#full_path' do let(:group) { create(:group) } let(:nested_group) { create(:group, parent: group) } diff --git a/spec/models/container_repository_spec.rb b/spec/models/container_repository_spec.rb new file mode 100644 index 00000000000..f7ee0b57072 --- /dev/null +++ b/spec/models/container_repository_spec.rb @@ -0,0 +1,209 @@ +require 'spec_helper' + +describe ContainerRepository do + let(:group) { create(:group, name: 'group') } + let(:project) { create(:project, path: 'test', group: group) } + + let(:container_repository) do + create(:container_repository, name: 'my_image', project: project) + end + + before do + stub_container_registry_config(enabled: true, + api_url: 'http://registry.gitlab', + host_port: 'registry.gitlab') + + stub_request(:get, 'http://registry.gitlab/v2/group/test/my_image/tags/list') + .with(headers: { 'Accept' => 'application/vnd.docker.distribution.manifest.v2+json' }) + .to_return( + status: 200, + body: JSON.dump(tags: ['test_tag']), + headers: { 'Content-Type' => 'application/json' }) + end + + describe 'associations' do + it 'belongs to the project' do + expect(container_repository).to belong_to(:project) + end + end + + describe '#tag' do + it 'has a test tag' do + expect(container_repository.tag('test')).not_to be_nil + end + end + + describe '#path' do + it 'returns a full path to the repository' do + expect(container_repository.path).to eq('group/test/my_image') + end + end + + describe '#manifest' do + subject { container_repository.manifest } + + it { is_expected.not_to be_nil } + end + + describe '#valid?' do + subject { container_repository.valid? } + + it { is_expected.to be_truthy } + end + + describe '#tags' do + subject { container_repository.tags } + + it { is_expected.not_to be_empty } + end + + describe '#has_tags?' do + it 'has tags' do + expect(container_repository).to have_tags + end + end + + describe '#delete_tags!' do + let(:container_repository) do + create(:container_repository, name: 'my_image', + tags: %w[latest rc1], + project: project) + end + + context 'when action succeeds' do + it 'returns status that indicates success' do + expect(container_repository.client) + .to receive(:delete_repository_tag) + .and_return(true) + + expect(container_repository.delete_tags!).to be_truthy + end + end + + context 'when action fails' do + it 'returns status that indicates failure' do + expect(container_repository.client) + .to receive(:delete_repository_tag) + .and_return(false) + + expect(container_repository.delete_tags!).to be_falsey + end + end + end + + describe '#root_repository?' do + context 'when repository is a root repository' do + let(:repository) { create(:container_repository, :root) } + + it 'returns true' do + expect(repository).to be_root_repository + end + end + + context 'when repository is not a root repository' do + it 'returns false' do + expect(container_repository).not_to be_root_repository + end + end + end + + describe '.build_from_path' do + let(:registry_path) do + ContainerRegistry::Path.new(project.full_path + '/some/image') + end + + let(:repository) do + described_class.build_from_path(registry_path) + end + + it 'fabricates repository assigned to a correct project' do + expect(repository.project).to eq project + end + + it 'fabricates repository with a correct name' do + expect(repository.name).to eq 'some/image' + end + + it 'is not persisted' do + expect(repository).not_to be_persisted + end + end + + describe '.create_from_path!' do + let(:repository) do + described_class.create_from_path!(ContainerRegistry::Path.new(path)) + end + + let(:repository_path) { ContainerRegistry::Path.new(path) } + + context 'when received multi-level repository path' do + let(:path) { project.full_path + '/some/image' } + + it 'fabricates repository assigned to a correct project' do + expect(repository.project).to eq project + end + + it 'fabricates repository with a correct name' do + expect(repository.name).to eq 'some/image' + end + end + + context 'when path is too long' do + let(:path) do + project.full_path + '/a/b/c/d/e/f/g/h/i/j/k/l/n/o/p/s/t/u/x/y/z' + end + + it 'does not create repository and raises error' do + expect { repository }.to raise_error( + ContainerRegistry::Path::InvalidRegistryPathError) + end + end + + context 'when received multi-level repository with nested groups' do + let(:group) { create(:group, :nested, name: 'nested') } + let(:path) { project.full_path + '/some/image' } + + it 'fabricates repository assigned to a correct project' do + expect(repository.project).to eq project + end + + it 'fabricates repository with a correct name' do + expect(repository.name).to eq 'some/image' + end + + it 'has path including a nested group' do + expect(repository.path).to include 'nested/test/some/image' + end + end + + context 'when received root repository path' do + let(:path) { project.full_path } + + it 'fabricates repository assigned to a correct project' do + expect(repository.project).to eq project + end + + it 'fabricates repository with an empty name' do + expect(repository.name).to be_empty + end + end + end + + describe '.build_root_repository' do + let(:repository) do + described_class.build_root_repository(project) + end + + it 'fabricates a root repository object' do + expect(repository).to be_root_repository + end + + it 'assignes it to the correct project' do + expect(repository.project).to eq project + end + + it 'does not persist it' do + expect(repository).not_to be_persisted + end + end +end diff --git a/spec/models/diff_discussion_spec.rb b/spec/models/diff_discussion_spec.rb new file mode 100644 index 00000000000..48e7c0a822c --- /dev/null +++ b/spec/models/diff_discussion_spec.rb @@ -0,0 +1,19 @@ +require 'spec_helper' + +describe DiffDiscussion, model: true do + subject { described_class.new([first_note, second_note, third_note]) } + + let(:first_note) { create(:diff_note_on_merge_request) } + let(:merge_request) { first_note.noteable } + let(:project) { first_note.project } + let(:second_note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project, in_reply_to: first_note) } + let(:third_note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project, in_reply_to: first_note) } + + describe '#reply_attributes' do + it 'includes position and original_position' do + attributes = subject.reply_attributes + expect(attributes[:position]).to eq(first_note.position.to_json) + expect(attributes[:original_position]).to eq(first_note.original_position.to_json) + end + end +end diff --git a/spec/models/diff_note_spec.rb b/spec/models/diff_note_spec.rb index 9ea3a4b7020..fb80b74b226 100644 --- a/spec/models/diff_note_spec.rb +++ b/spec/models/diff_note_spec.rb @@ -31,43 +31,6 @@ describe DiffNote, models: true do subject { create(:diff_note_on_merge_request, project: project, position: position, noteable: merge_request) } - describe ".resolve!" do - let(:current_user) { create(:user) } - let!(:commit_note) { create(:diff_note_on_commit) } - let!(:resolved_note) { create(:diff_note_on_merge_request, :resolved) } - let!(:unresolved_note) { create(:diff_note_on_merge_request) } - - before do - described_class.resolve!(current_user) - - commit_note.reload - resolved_note.reload - unresolved_note.reload - end - - it 'resolves only the resolvable, not yet resolved notes' do - expect(commit_note.resolved_at).to be_nil - expect(resolved_note.resolved_by).not_to eq(current_user) - expect(unresolved_note.resolved_at).not_to be_nil - expect(unresolved_note.resolved_by).to eq(current_user) - end - end - - describe ".unresolve!" do - let!(:resolved_note) { create(:diff_note_on_merge_request, :resolved) } - - before do - described_class.unresolve! - - resolved_note.reload - end - - it 'unresolves the resolved notes' do - expect(resolved_note.resolved_by).to be_nil - expect(resolved_note.resolved_at).to be_nil - end - end - describe "#position=" do context "when provided a string" do it "sets the position" do @@ -94,6 +57,32 @@ describe DiffNote, models: true do end end + describe "#original_position=" do + context "when provided a string" do + it "sets the original position" do + subject.original_position = new_position.to_json + + expect(subject.original_position).to eq(new_position) + end + end + + context "when provided a hash" do + it "sets the original position" do + subject.original_position = new_position.to_h + + expect(subject.original_position).to eq(new_position) + end + end + + context "when provided a position object" do + it "sets the original position" do + subject.original_position = new_position + + expect(subject.original_position).to eq(new_position) + end + end + end + describe "#diff_file" do it "returns the correct diff file" do diff_file = subject.diff_file @@ -226,252 +215,6 @@ describe DiffNote, models: true do end end - describe "#resolvable?" do - context "when noteable is a commit" do - subject { create(:diff_note_on_commit, project: project, position: position) } - - it "returns false" do - expect(subject.resolvable?).to be false - end - end - - context "when noteable is a merge request" do - context "when a system note" do - before do - subject.system = true - end - - it "returns false" do - expect(subject.resolvable?).to be false - end - end - - context "when a regular note" do - it "returns true" do - expect(subject.resolvable?).to be true - end - end - end - end - - describe "#to_be_resolved?" do - context "when not resolvable" do - before do - allow(subject).to receive(:resolvable?).and_return(false) - end - - it "returns false" do - expect(subject.to_be_resolved?).to be false - end - end - - context "when resolvable" do - before do - allow(subject).to receive(:resolvable?).and_return(true) - end - - context "when resolved" do - before do - allow(subject).to receive(:resolved?).and_return(true) - end - - it "returns false" do - expect(subject.to_be_resolved?).to be false - end - end - - context "when not resolved" do - before do - allow(subject).to receive(:resolved?).and_return(false) - end - - it "returns true" do - expect(subject.to_be_resolved?).to be true - end - end - end - end - - describe "#resolve!" do - let(:current_user) { create(:user) } - - context "when not resolvable" do - before do - allow(subject).to receive(:resolvable?).and_return(false) - end - - it "returns nil" do - expect(subject.resolve!(current_user)).to be_nil - end - - it "doesn't set resolved_at" do - subject.resolve!(current_user) - - expect(subject.resolved_at).to be_nil - end - - it "doesn't set resolved_by" do - subject.resolve!(current_user) - - expect(subject.resolved_by).to be_nil - end - - it "doesn't mark as resolved" do - subject.resolve!(current_user) - - expect(subject.resolved?).to be false - end - end - - context "when resolvable" do - before do - allow(subject).to receive(:resolvable?).and_return(true) - end - - context "when already resolved" do - let(:user) { create(:user) } - - before do - subject.resolve!(user) - end - - it "returns nil" do - expect(subject.resolve!(current_user)).to be_nil - end - - it "doesn't change resolved_at" do - expect(subject.resolved_at).not_to be_nil - - expect { subject.resolve!(current_user) }.not_to change { subject.resolved_at } - end - - it "doesn't change resolved_by" do - expect(subject.resolved_by).to eq(user) - - expect { subject.resolve!(current_user) }.not_to change { subject.resolved_by } - end - - it "doesn't change resolved status" do - expect(subject.resolved?).to be true - - expect { subject.resolve!(current_user) }.not_to change { subject.resolved? } - end - end - - context "when not yet resolved" do - it "returns true" do - expect(subject.resolve!(current_user)).to be true - end - - it "sets resolved_at" do - subject.resolve!(current_user) - - expect(subject.resolved_at).not_to be_nil - end - - it "sets resolved_by" do - subject.resolve!(current_user) - - expect(subject.resolved_by).to eq(current_user) - end - - it "marks as resolved" do - subject.resolve!(current_user) - - expect(subject.resolved?).to be true - end - end - end - end - - describe "#unresolve!" do - context "when not resolvable" do - before do - allow(subject).to receive(:resolvable?).and_return(false) - end - - it "returns nil" do - expect(subject.unresolve!).to be_nil - end - end - - context "when resolvable" do - before do - allow(subject).to receive(:resolvable?).and_return(true) - end - - context "when resolved" do - let(:user) { create(:user) } - - before do - subject.resolve!(user) - end - - it "returns true" do - expect(subject.unresolve!).to be true - end - - it "unsets resolved_at" do - subject.unresolve! - - expect(subject.resolved_at).to be_nil - end - - it "unsets resolved_by" do - subject.unresolve! - - expect(subject.resolved_by).to be_nil - end - - it "unmarks as resolved" do - subject.unresolve! - - expect(subject.resolved?).to be false - end - end - - context "when not resolved" do - it "returns nil" do - expect(subject.unresolve!).to be_nil - end - end - end - end - - describe "#discussion" do - context "when not resolvable" do - before do - allow(subject).to receive(:resolvable?).and_return(false) - end - - it "returns nil" do - expect(subject.discussion).to be_nil - end - end - - context "when resolvable" do - let!(:diff_note2) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: subject.position) } - let!(:diff_note3) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: active_position2) } - - let(:active_position2) do - Gitlab::Diff::Position.new( - old_path: "files/ruby/popen.rb", - new_path: "files/ruby/popen.rb", - old_line: 16, - new_line: 22, - diff_refs: merge_request.diff_refs - ) - end - - it "returns the discussion this note is in" do - discussion = subject.discussion - - expect(discussion.id).to eq(subject.discussion_id) - expect(discussion.notes).to eq([subject, diff_note2]) - end - end - end - describe "#discussion_id" do let(:note) { create(:diff_note_on_merge_request) } @@ -496,29 +239,4 @@ describe DiffNote, models: true do end end end - - describe "#original_discussion_id" do - let(:note) { create(:diff_note_on_merge_request) } - - context "when it is newly created" do - it "has a discussion id" do - expect(note.original_discussion_id).not_to be_nil - expect(note.original_discussion_id).to match(/\A\h{40}\z/) - end - end - - context "when it didn't store a discussion id before" do - before do - note.update_column(:original_discussion_id, nil) - end - - it "has a discussion id" do - # The original_discussion_id is set in `after_initialize`, so `reload` won't work - reloaded_note = Note.find(note.id) - - expect(reloaded_note.original_discussion_id).not_to be_nil - expect(reloaded_note.original_discussion_id).to match(/\A\h{40}\z/) - end - end - end end diff --git a/spec/models/discussion_spec.rb b/spec/models/discussion_spec.rb index bc32fadd391..0221e23ced8 100644 --- a/spec/models/discussion_spec.rb +++ b/spec/models/discussion_spec.rb @@ -4,618 +4,27 @@ describe Discussion, model: true do subject { described_class.new([first_note, second_note, third_note]) } let(:first_note) { create(:diff_note_on_merge_request) } - let(:second_note) { create(:diff_note_on_merge_request) } + let(:merge_request) { first_note.noteable } + let(:second_note) { create(:diff_note_on_merge_request, in_reply_to: first_note) } let(:third_note) { create(:diff_note_on_merge_request) } - describe "#resolvable?" do - context "when a diff discussion" do - before do - allow(subject).to receive(:diff_discussion?).and_return(true) - end - - context "when all notes are unresolvable" do - before do - allow(first_note).to receive(:resolvable?).and_return(false) - allow(second_note).to receive(:resolvable?).and_return(false) - allow(third_note).to receive(:resolvable?).and_return(false) - end - - it "returns false" do - expect(subject.resolvable?).to be false - end - end - - context "when some notes are unresolvable and some notes are resolvable" do - before do - allow(first_note).to receive(:resolvable?).and_return(true) - allow(second_note).to receive(:resolvable?).and_return(false) - allow(third_note).to receive(:resolvable?).and_return(true) - end - - it "returns true" do - expect(subject.resolvable?).to be true - end - end - - context "when all notes are resolvable" do - before do - allow(first_note).to receive(:resolvable?).and_return(true) - allow(second_note).to receive(:resolvable?).and_return(true) - allow(third_note).to receive(:resolvable?).and_return(true) - end - - it "returns true" do - expect(subject.resolvable?).to be true - end - end - end - - context "when not a diff discussion" do - before do - allow(subject).to receive(:diff_discussion?).and_return(false) - end - - it "returns false" do - expect(subject.resolvable?).to be false - end - end - end - - describe "#resolved?" do - context "when not resolvable" do - before do - allow(subject).to receive(:resolvable?).and_return(false) - end - - it "returns false" do - expect(subject.resolved?).to be false - end - end - - context "when resolvable" do - before do - allow(subject).to receive(:resolvable?).and_return(true) - - allow(first_note).to receive(:resolvable?).and_return(true) - allow(second_note).to receive(:resolvable?).and_return(false) - allow(third_note).to receive(:resolvable?).and_return(true) - end - - context "when all resolvable notes are resolved" do - before do - allow(first_note).to receive(:resolved?).and_return(true) - allow(third_note).to receive(:resolved?).and_return(true) - end - - it "returns true" do - expect(subject.resolved?).to be true - end - end - - context "when some resolvable notes are not resolved" do - before do - allow(first_note).to receive(:resolved?).and_return(true) - allow(third_note).to receive(:resolved?).and_return(false) - end - - it "returns false" do - expect(subject.resolved?).to be false - end - end - end - end - - describe "#to_be_resolved?" do - context "when not resolvable" do - before do - allow(subject).to receive(:resolvable?).and_return(false) - end - - it "returns false" do - expect(subject.to_be_resolved?).to be false - end - end - - context "when resolvable" do - before do - allow(subject).to receive(:resolvable?).and_return(true) - - allow(first_note).to receive(:resolvable?).and_return(true) - allow(second_note).to receive(:resolvable?).and_return(false) - allow(third_note).to receive(:resolvable?).and_return(true) - end - - context "when all resolvable notes are resolved" do - before do - allow(first_note).to receive(:resolved?).and_return(true) - allow(third_note).to receive(:resolved?).and_return(true) - end - - it "returns false" do - expect(subject.to_be_resolved?).to be false - end - end - - context "when some resolvable notes are not resolved" do - before do - allow(first_note).to receive(:resolved?).and_return(true) - allow(third_note).to receive(:resolved?).and_return(false) - end - - it "returns true" do - expect(subject.to_be_resolved?).to be true - end - end - end - end - - describe "#can_resolve?" do - let(:current_user) { create(:user) } - - context "when not resolvable" do - before do - allow(subject).to receive(:resolvable?).and_return(false) - end - - it "returns false" do - expect(subject.can_resolve?(current_user)).to be false - end - end - - context "when resolvable" do - before do - allow(subject).to receive(:resolvable?).and_return(true) - end - - context "when not signed in" do - let(:current_user) { nil } - - it "returns false" do - expect(subject.can_resolve?(current_user)).to be false - end - end - - context "when signed in" do - context "when the signed in user is the noteable author" do - before do - subject.noteable.author = current_user - end - - it "returns true" do - expect(subject.can_resolve?(current_user)).to be true - end - end - - context "when the signed in user can push to the project" do - before do - subject.project.team << [current_user, :master] - end - - it "returns true" do - expect(subject.can_resolve?(current_user)).to be true - end - end - - context "when the signed in user is a random user" do - it "returns false" do - expect(subject.can_resolve?(current_user)).to be false - end - end - end - end - end - - describe "#resolve!" do - let(:current_user) { create(:user) } - - context "when not resolvable" do - before do - allow(subject).to receive(:resolvable?).and_return(false) - end - - it "returns nil" do - expect(subject.resolve!(current_user)).to be_nil - end - - it "doesn't set resolved_at" do - subject.resolve!(current_user) - - expect(subject.resolved_at).to be_nil - end - - it "doesn't set resolved_by" do - subject.resolve!(current_user) - - expect(subject.resolved_by).to be_nil - end - - it "doesn't mark as resolved" do - subject.resolve!(current_user) - - expect(subject.resolved?).to be false - end - end - - context "when resolvable" do - let(:user) { create(:user) } - let(:second_note) { create(:diff_note_on_commit) } # unresolvable - - before do - allow(subject).to receive(:resolvable?).and_return(true) - end - - context "when all resolvable notes are resolved" do - before do - first_note.resolve!(user) - third_note.resolve!(user) - - first_note.reload - third_note.reload - end - - it "doesn't change resolved_at on the resolved notes" do - expect(first_note.resolved_at).not_to be_nil - expect(third_note.resolved_at).not_to be_nil - - expect { subject.resolve!(current_user) }.not_to change { first_note.resolved_at } - expect { subject.resolve!(current_user) }.not_to change { third_note.resolved_at } - end - - it "doesn't change resolved_by on the resolved notes" do - expect(first_note.resolved_by).to eq(user) - expect(third_note.resolved_by).to eq(user) - - expect { subject.resolve!(current_user) }.not_to change { first_note.resolved_by } - expect { subject.resolve!(current_user) }.not_to change { third_note.resolved_by } - end - - it "doesn't change the resolved state on the resolved notes" do - expect(first_note.resolved?).to be true - expect(third_note.resolved?).to be true - - expect { subject.resolve!(current_user) }.not_to change { first_note.resolved? } - expect { subject.resolve!(current_user) }.not_to change { third_note.resolved? } - end - - it "doesn't change resolved_at" do - expect(subject.resolved_at).not_to be_nil - - expect { subject.resolve!(current_user) }.not_to change { subject.resolved_at } - end - - it "doesn't change resolved_by" do - expect(subject.resolved_by).to eq(user) - - expect { subject.resolve!(current_user) }.not_to change { subject.resolved_by } - end - - it "doesn't change resolved state" do - expect(subject.resolved?).to be true - - expect { subject.resolve!(current_user) }.not_to change { subject.resolved? } - end - end - - context "when some resolvable notes are resolved" do - before do - first_note.resolve!(user) - end - - it "doesn't change resolved_at on the resolved note" do - expect(first_note.resolved_at).not_to be_nil - - expect { subject.resolve!(current_user) }. - not_to change { first_note.reload.resolved_at } - end - - it "doesn't change resolved_by on the resolved note" do - expect(first_note.resolved_by).to eq(user) - - expect { subject.resolve!(current_user) }. - not_to change { first_note.reload && first_note.resolved_by } - end - - it "doesn't change the resolved state on the resolved note" do - expect(first_note.resolved?).to be true - - expect { subject.resolve!(current_user) }. - not_to change { first_note.reload && first_note.resolved? } - end - - it "sets resolved_at on the unresolved note" do - subject.resolve!(current_user) - third_note.reload - - expect(third_note.resolved_at).not_to be_nil - end - - it "sets resolved_by on the unresolved note" do - subject.resolve!(current_user) - third_note.reload - - expect(third_note.resolved_by).to eq(current_user) - end - - it "marks the unresolved note as resolved" do - subject.resolve!(current_user) - third_note.reload - - expect(third_note.resolved?).to be true - end - - it "sets resolved_at" do - subject.resolve!(current_user) - - expect(subject.resolved_at).not_to be_nil - end - - it "sets resolved_by" do - subject.resolve!(current_user) - - expect(subject.resolved_by).to eq(current_user) - end - - it "marks as resolved" do - subject.resolve!(current_user) - - expect(subject.resolved?).to be true - end - end - - context "when no resolvable notes are resolved" do - it "sets resolved_at on the unresolved notes" do - subject.resolve!(current_user) - first_note.reload - third_note.reload - - expect(first_note.resolved_at).not_to be_nil - expect(third_note.resolved_at).not_to be_nil - end - - it "sets resolved_by on the unresolved notes" do - subject.resolve!(current_user) - first_note.reload - third_note.reload - - expect(first_note.resolved_by).to eq(current_user) - expect(third_note.resolved_by).to eq(current_user) - end - - it "marks the unresolved notes as resolved" do - subject.resolve!(current_user) - first_note.reload - third_note.reload - - expect(first_note.resolved?).to be true - expect(third_note.resolved?).to be true - end - - it "sets resolved_at" do - subject.resolve!(current_user) - first_note.reload - third_note.reload - - expect(subject.resolved_at).not_to be_nil - end - - it "sets resolved_by" do - subject.resolve!(current_user) - first_note.reload - third_note.reload - - expect(subject.resolved_by).to eq(current_user) - end - - it "marks as resolved" do - subject.resolve!(current_user) - first_note.reload - third_note.reload - - expect(subject.resolved?).to be true - end - end - end - end - - describe "#unresolve!" do - context "when not resolvable" do - before do - allow(subject).to receive(:resolvable?).and_return(false) - end - - it "returns nil" do - expect(subject.unresolve!).to be_nil - end - end - - context "when resolvable" do - let(:user) { create(:user) } - - before do - allow(subject).to receive(:resolvable?).and_return(true) - - allow(first_note).to receive(:resolvable?).and_return(true) - allow(second_note).to receive(:resolvable?).and_return(false) - allow(third_note).to receive(:resolvable?).and_return(true) - end - - context "when all resolvable notes are resolved" do - before do - first_note.resolve!(user) - third_note.resolve!(user) - end - - it "unsets resolved_at on the resolved notes" do - subject.unresolve! - first_note.reload - third_note.reload - - expect(first_note.resolved_at).to be_nil - expect(third_note.resolved_at).to be_nil - end - - it "unsets resolved_by on the resolved notes" do - subject.unresolve! - first_note.reload - third_note.reload - - expect(first_note.resolved_by).to be_nil - expect(third_note.resolved_by).to be_nil - end - - it "unmarks the resolved notes as resolved" do - subject.unresolve! - first_note.reload - third_note.reload - - expect(first_note.resolved?).to be false - expect(third_note.resolved?).to be false - end - - it "unsets resolved_at" do - subject.unresolve! - first_note.reload - third_note.reload - - expect(subject.resolved_at).to be_nil - end - - it "unsets resolved_by" do - subject.unresolve! - first_note.reload - third_note.reload - - expect(subject.resolved_by).to be_nil - end - - it "unmarks as resolved" do - subject.unresolve! - - expect(subject.resolved?).to be false - end - end - - context "when some resolvable notes are resolved" do - before do - first_note.resolve!(user) - end - - it "unsets resolved_at on the resolved note" do - subject.unresolve! - - expect(subject.first_note.resolved_at).to be_nil - end - - it "unsets resolved_by on the resolved note" do - subject.unresolve! - - expect(subject.first_note.resolved_by).to be_nil - end - - it "unmarks the resolved note as resolved" do - subject.unresolve! - - expect(subject.first_note.resolved?).to be false - end - end + describe '.build' do + it 'returns a discussion of the right type' do + discussion = described_class.build([first_note, second_note], merge_request) + expect(discussion).to be_a(DiffDiscussion) + expect(discussion.notes.count).to be(2) + expect(discussion.first_note).to be(first_note) + expect(discussion.noteable).to be(merge_request) end end - describe "#first_note_to_resolve" do - it "returns the first not that still needs to be resolved" do - allow(first_note).to receive(:to_be_resolved?).and_return(false) - allow(second_note).to receive(:to_be_resolved?).and_return(true) - - expect(subject.first_note_to_resolve).to eq(second_note) - end - end - - describe "#collapsed?" do - context "when a diff discussion" do - before do - allow(subject).to receive(:diff_discussion?).and_return(true) - end - - context "when resolvable" do - before do - allow(subject).to receive(:resolvable?).and_return(true) - end - - context "when resolved" do - before do - allow(subject).to receive(:resolved?).and_return(true) - end - - it "returns true" do - expect(subject.collapsed?).to be true - end - end - - context "when not resolved" do - before do - allow(subject).to receive(:resolved?).and_return(false) - end - - it "returns false" do - expect(subject.collapsed?).to be false - end - end - end - - context "when not resolvable" do - before do - allow(subject).to receive(:resolvable?).and_return(false) - end - - context "when active" do - before do - allow(subject).to receive(:active?).and_return(true) - end - - it "returns false" do - expect(subject.collapsed?).to be false - end - end - - context "when outdated" do - before do - allow(subject).to receive(:active?).and_return(false) - end - - it "returns true" do - expect(subject.collapsed?).to be true - end - end - end - end - - context "when not a diff discussion" do - before do - allow(subject).to receive(:diff_discussion?).and_return(false) - end - - it "returns false" do - expect(subject.collapsed?).to be false - end - end - end - - describe "#truncated_diff_lines" do - let(:truncated_lines) { subject.truncated_diff_lines } - - context "when diff is greater than allowed number of truncated diff lines " do - it "returns fewer lines" do - expect(subject.diff_lines.count).to be > described_class::NUMBER_OF_TRUNCATED_DIFF_LINES - - expect(truncated_lines.count).to be <= described_class::NUMBER_OF_TRUNCATED_DIFF_LINES - end - end - - context "when some diff lines are meta" do - it "returns no meta lines" do - expect(subject.diff_lines).to include(be_meta) - expect(truncated_lines).not_to include(be_meta) - end + describe '.build_collection' do + it 'returns an array of discussions of the right type' do + discussions = described_class.build_collection([first_note, second_note, third_note], merge_request) + expect(discussions).to eq([ + DiffDiscussion.new([first_note, second_note], merge_request), + DiffDiscussion.new([third_note], merge_request) + ]) end end end diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index 9f0e7fbbe26..af7753caba6 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -100,12 +100,26 @@ describe Environment, models: true do let(:head_commit) { project.commit } let(:commit) { project.commit.parent } - it 'returns deployment id for the environment' do - expect(environment.first_deployment_for(commit)).to eq deployment1 + context 'Gitaly find_ref_name feature disabled' do + it 'returns deployment id for the environment' do + expect(environment.first_deployment_for(commit)).to eq deployment1 + end + + it 'return nil when no deployment is found' do + expect(environment.first_deployment_for(head_commit)).to eq nil + end end - it 'return nil when no deployment is found' do - expect(environment.first_deployment_for(head_commit)).to eq nil + context 'Gitaly find_ref_name feature enabled' do + before do + allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(:find_ref_name).and_return(true) + end + + it 'calls GitalyClient' do + expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:find_ref_name) + + environment.first_deployment_for(commit) + end end end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 5d87938235a..8ffde6f7fbb 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -55,6 +55,8 @@ describe Group, models: true do it { is_expected.to validate_uniqueness_of(:name).scoped_to(:parent_id) } it { is_expected.to validate_presence_of :path } it { is_expected.not_to validate_presence_of :owner } + it { is_expected.to validate_presence_of :two_factor_grace_period } + it { is_expected.to validate_numericality_of(:two_factor_grace_period).is_greater_than_or_equal_to(0) } end describe '.visible_to_user' do @@ -315,4 +317,44 @@ describe Group, models: true do to include(master.id, developer.id) end end + + describe '#update_two_factor_requirement' do + let(:user) { create(:user) } + + before do + group.add_user(user, GroupMember::OWNER) + end + + it 'is called when require_two_factor_authentication is changed' do + expect_any_instance_of(User).to receive(:update_two_factor_requirement) + + group.update!(require_two_factor_authentication: true) + end + + it 'is called when two_factor_grace_period is changed' do + expect_any_instance_of(User).to receive(:update_two_factor_requirement) + + group.update!(two_factor_grace_period: 23) + end + + it 'is not called when other attributes are changed' do + expect_any_instance_of(User).not_to receive(:update_two_factor_requirement) + + group.update!(description: 'foobar') + end + + it 'calls #update_two_factor_requirement on each group member' do + other_user = create(:user) + group.add_user(other_user, GroupMember::OWNER) + + calls = 0 + allow_any_instance_of(User).to receive(:update_two_factor_requirement) do + calls += 1 + end + + group.update!(require_two_factor_authentication: true, two_factor_grace_period: 23) + + expect(calls).to eq 2 + end + end end diff --git a/spec/models/legacy_diff_discussion_spec.rb b/spec/models/legacy_diff_discussion_spec.rb new file mode 100644 index 00000000000..153e757a0ef --- /dev/null +++ b/spec/models/legacy_diff_discussion_spec.rb @@ -0,0 +1,11 @@ +require 'spec_helper' + +describe LegacyDiffDiscussion, models: true do + subject { create(:legacy_diff_note_on_merge_request).to_discussion } + + describe '#reply_attributes' do + it 'includes line_code' do + expect(subject.reply_attributes[:line_code]).to eq(subject.line_code) + end + end +end diff --git a/spec/models/legacy_diff_note_spec.rb b/spec/models/legacy_diff_note_spec.rb deleted file mode 100644 index 81517a18b74..00000000000 --- a/spec/models/legacy_diff_note_spec.rb +++ /dev/null @@ -1,101 +0,0 @@ -require 'spec_helper' - -describe LegacyDiffNote, models: true do - describe "Commit diff line notes" do - let!(:note) { create(:legacy_diff_note_on_commit, note: "+1 from me") } - let!(:commit) { note.noteable } - - it "saves a valid note" do - expect(note.commit_id).to eq(commit.id) - expect(note.noteable.id).to eq(commit.id) - end - - it "is recognized by #legacy_diff_note?" do - expect(note).to be_legacy_diff_note - end - end - - describe '#active?' do - it 'is always true when the note has no associated diff line' do - note = build(:legacy_diff_note_on_merge_request) - - expect(note).to receive(:diff_line).and_return(nil) - - expect(note).to be_active - end - - it 'is never true when the note has no noteable associated' do - note = build(:legacy_diff_note_on_merge_request) - - expect(note).to receive(:diff_line).and_return(double) - expect(note).to receive(:noteable).and_return(nil) - - expect(note).not_to be_active - end - - it 'returns the memoized value if defined' do - note = build(:legacy_diff_note_on_merge_request) - - note.instance_variable_set(:@active, 'foo') - expect(note).not_to receive(:find_noteable_diff) - - expect(note.active?).to eq 'foo' - end - - context 'for a merge request noteable' do - it 'is false when noteable has no matching diff' do - merge = build_stubbed(:merge_request, :simple) - note = build(:legacy_diff_note_on_merge_request, noteable: merge) - - allow(note).to receive(:diff_line).and_return(double) - expect(note).to receive(:find_noteable_diff).and_return(nil) - - expect(note).not_to be_active - end - - it 'is true when noteable has a matching diff' do - merge = create(:merge_request, :simple) - - # Generate a real line_code value so we know it will match. We use a - # random line from a random diff just for funsies. - diff = merge.raw_diffs.to_a.sample - line = Gitlab::Diff::Parser.new.parse(diff.diff.each_line).to_a.sample - code = Gitlab::Diff::LineCode.generate(diff.new_path, line.new_pos, line.old_pos) - - # We're persisting in order to trigger the set_diff callback - note = create(:legacy_diff_note_on_merge_request, noteable: merge, - line_code: code, - project: merge.source_project) - - # Make sure we don't get a false positive from a guard clause - expect(note).to receive(:find_noteable_diff).and_call_original - expect(note).to be_active - end - end - end - - describe "#discussion_id" do - let(:note) { create(:note) } - - context "when it is newly created" do - it "has a discussion id" do - expect(note.discussion_id).not_to be_nil - expect(note.discussion_id).to match(/\A\h{40}\z/) - end - end - - context "when it didn't store a discussion id before" do - before do - note.update_column(:discussion_id, nil) - end - - it "has a discussion id" do - # The discussion_id is set in `after_initialize`, so `reload` won't work - reloaded_note = Note.find(note.id) - - expect(reloaded_note.discussion_id).not_to be_nil - expect(reloaded_note.discussion_id).to match(/\A\h{40}\z/) - end - end - end -end diff --git a/spec/models/members/group_member_spec.rb b/spec/models/members/group_member_spec.rb index 370aeb9e0a9..024380b7ebb 100644 --- a/spec/models/members/group_member_spec.rb +++ b/spec/models/members/group_member_spec.rb @@ -61,7 +61,7 @@ describe GroupMember, models: true do 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) + member = create(:group_member, user: build(:user), requested_at: Time.now) expect_any_instance_of(NotificationService).to receive(:new_group_member) @@ -75,4 +75,19 @@ describe GroupMember, models: true do it { is_expected.to eq 'Group' } end end + + describe '#update_two_factor_requirement' do + let(:user) { build :user } + let(:group_member) { build :group_member, user: user } + + it 'is called after creation and deletion' do + expect(user).to receive(:update_two_factor_requirement) + + group_member.save + + expect(user).to receive(:update_two_factor_requirement) + + group_member.destroy + end + end end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 24e7c1b17d9..b615d956686 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -1224,182 +1224,6 @@ describe MergeRequest, models: true do end end - context "discussion status" do - let(:first_discussion) { Discussion.new([create(:diff_note_on_merge_request)]) } - let(:second_discussion) { Discussion.new([create(:diff_note_on_merge_request)]) } - let(:third_discussion) { Discussion.new([create(:diff_note_on_merge_request)]) } - - before do - allow(subject).to receive(:diff_discussions).and_return([first_discussion, second_discussion, third_discussion]) - end - - describe '#resolvable_discussions' do - before do - allow(first_discussion).to receive(:to_be_resolved?).and_return(true) - allow(second_discussion).to receive(:to_be_resolved?).and_return(false) - allow(third_discussion).to receive(:to_be_resolved?).and_return(false) - end - - it 'includes only discussions that need to be resolved' do - expect(subject.resolvable_discussions).to eq([first_discussion]) - end - end - - describe '#discussions_can_be_resolved_by? user' do - let(:user) { build(:user) } - - context 'all discussions can be resolved by the user' do - before do - allow(first_discussion).to receive(:can_resolve?).with(user).and_return(true) - allow(second_discussion).to receive(:can_resolve?).with(user).and_return(true) - allow(third_discussion).to receive(:can_resolve?).with(user).and_return(true) - end - - it 'allows a user to resolve the discussions' do - expect(subject.discussions_can_be_resolved_by?(user)).to be(true) - end - end - - context 'one discussion cannot be resolved by the user' do - before do - allow(first_discussion).to receive(:can_resolve?).with(user).and_return(true) - allow(second_discussion).to receive(:can_resolve?).with(user).and_return(true) - allow(third_discussion).to receive(:can_resolve?).with(user).and_return(false) - end - - it 'allows a user to resolve the discussions' do - expect(subject.discussions_can_be_resolved_by?(user)).to be(false) - end - end - end - - describe "#discussions_resolvable?" do - context "when all discussions are unresolvable" do - before do - allow(first_discussion).to receive(:resolvable?).and_return(false) - allow(second_discussion).to receive(:resolvable?).and_return(false) - allow(third_discussion).to receive(:resolvable?).and_return(false) - end - - it "returns false" do - expect(subject.discussions_resolvable?).to be false - end - end - - context "when some discussions are unresolvable and some discussions are resolvable" do - before do - allow(first_discussion).to receive(:resolvable?).and_return(true) - allow(second_discussion).to receive(:resolvable?).and_return(false) - allow(third_discussion).to receive(:resolvable?).and_return(true) - end - - it "returns true" do - expect(subject.discussions_resolvable?).to be true - end - end - - context "when all discussions are resolvable" do - before do - allow(first_discussion).to receive(:resolvable?).and_return(true) - allow(second_discussion).to receive(:resolvable?).and_return(true) - allow(third_discussion).to receive(:resolvable?).and_return(true) - end - - it "returns true" do - expect(subject.discussions_resolvable?).to be true - end - end - end - - describe "#discussions_resolved?" do - context "when discussions are not resolvable" do - before do - allow(subject).to receive(:discussions_resolvable?).and_return(false) - end - - it "returns false" do - expect(subject.discussions_resolved?).to be false - end - end - - context "when discussions are resolvable" do - before do - allow(subject).to receive(:discussions_resolvable?).and_return(true) - - allow(first_discussion).to receive(:resolvable?).and_return(true) - allow(second_discussion).to receive(:resolvable?).and_return(false) - allow(third_discussion).to receive(:resolvable?).and_return(true) - end - - context "when all resolvable discussions are resolved" do - before do - allow(first_discussion).to receive(:resolved?).and_return(true) - allow(third_discussion).to receive(:resolved?).and_return(true) - end - - it "returns true" do - expect(subject.discussions_resolved?).to be true - end - end - - context "when some resolvable discussions are not resolved" do - before do - allow(first_discussion).to receive(:resolved?).and_return(true) - allow(third_discussion).to receive(:resolved?).and_return(false) - end - - it "returns false" do - expect(subject.discussions_resolved?).to be false - end - end - end - end - - describe "#discussions_to_be_resolved?" do - context "when discussions are not resolvable" do - before do - allow(subject).to receive(:discussions_resolvable?).and_return(false) - end - - it "returns false" do - expect(subject.discussions_to_be_resolved?).to be false - end - end - - context "when discussions are resolvable" do - before do - allow(subject).to receive(:discussions_resolvable?).and_return(true) - - allow(first_discussion).to receive(:resolvable?).and_return(true) - allow(second_discussion).to receive(:resolvable?).and_return(false) - allow(third_discussion).to receive(:resolvable?).and_return(true) - end - - context "when all resolvable discussions are resolved" do - before do - allow(first_discussion).to receive(:resolved?).and_return(true) - allow(third_discussion).to receive(:resolved?).and_return(true) - end - - it "returns false" do - expect(subject.discussions_to_be_resolved?).to be false - end - end - - context "when some resolvable discussions are not resolved" do - before do - allow(first_discussion).to receive(:resolved?).and_return(true) - allow(third_discussion).to receive(:resolved?).and_return(false) - end - - it "returns true" do - expect(subject.discussions_to_be_resolved?).to be true - end - end - end - end - end - describe '#conflicts_can_be_resolved_in_ui?' do def create_merge_request(source_branch) create(:merge_request, source_branch: source_branch, target_branch: 'conflict-start') do |mr| diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index d9216112259..e406d0a16bd 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -148,18 +148,22 @@ describe Namespace, models: true do expect(@namespace.move_dir).to be_truthy end - context "when any project has container tags" do + context "when any project has container images" do + let(:container_repository) { create(:container_repository) } + before do stub_container_registry_config(enabled: true) - stub_container_registry_tags('tag') + stub_container_registry_tags(repository: :any, tags: ['tag']) - create(:empty_project, namespace: @namespace) + create(:empty_project, namespace: @namespace, container_repositories: [container_repository]) allow(@namespace).to receive(:path_was).and_return(@namespace.path) allow(@namespace).to receive(:path).and_return('new_path') end - it { expect { @namespace.move_dir }.to raise_error('Namespace cannot be moved, because at least one project has tags in container registry') } + it 'raises an error about not movable project' do + expect { @namespace.move_dir }.to raise_error(/Namespace cannot be moved/) + end end context 'with subgroups' do diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb index 33536487c41..3c4bf3f4ddb 100644 --- a/spec/models/note_spec.rb +++ b/spec/models/note_spec.rb @@ -245,14 +245,28 @@ describe Note, models: true do end end + describe '.find_discussion' do + let!(:note) { create(:discussion_note_on_merge_request) } + let!(:note2) { create(:discussion_note_on_merge_request, in_reply_to: note) } + let(:merge_request) { note.noteable } + + it 'returns a discussion with multiple notes' do + discussion = merge_request.notes.find_discussion(note.discussion_id) + + expect(discussion).not_to be_nil + expect(discussion.notes).to match_array([note, note2]) + expect(discussion.first_note.discussion_id).to eq(note.discussion_id) + end + end + describe ".grouped_diff_discussions" do let!(:merge_request) { create(:merge_request) } let(:project) { merge_request.project } let!(:active_diff_note1) { create(:diff_note_on_merge_request, project: project, noteable: merge_request) } - let!(:active_diff_note2) { create(:diff_note_on_merge_request, project: project, noteable: merge_request) } + let!(:active_diff_note2) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, in_reply_to: active_diff_note1) } let!(:active_diff_note3) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: active_position2) } let!(:outdated_diff_note1) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: outdated_position) } - let!(:outdated_diff_note2) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: outdated_position) } + let!(:outdated_diff_note2) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, in_reply_to: outdated_diff_note1) } let(:active_position2) do Gitlab::Diff::Position.new( @@ -277,7 +291,7 @@ describe Note, models: true do subject { merge_request.notes.grouped_diff_discussions } it "includes active discussions" do - discussions = subject.values + discussions = subject.values.flatten expect(discussions.count).to eq(2) expect(discussions.map(&:id)).to eq([active_diff_note1.discussion_id, active_diff_note3.discussion_id]) @@ -288,37 +302,12 @@ describe Note, models: true do end it "doesn't include outdated discussions" do - expect(subject.values.map(&:id)).not_to include(outdated_diff_note1.discussion_id) + expect(subject.values.flatten.map(&:id)).not_to include(outdated_diff_note1.discussion_id) end it "groups the discussions by line code" do - expect(subject[active_diff_note1.line_code].id).to eq(active_diff_note1.discussion_id) - expect(subject[active_diff_note3.line_code].id).to eq(active_diff_note3.discussion_id) - end - end - - describe "#discussion_id" do - let(:note) { create(:note) } - - context "when it is newly created" do - it "has a discussion id" do - expect(note.discussion_id).not_to be_nil - expect(note.discussion_id).to match(/\A\h{40}\z/) - end - end - - context "when it didn't store a discussion id before" do - before do - note.update_column(:discussion_id, nil) - end - - it "has a discussion id" do - # The discussion_id is set in `after_initialize`, so `reload` won't work - reloaded_note = Note.find(note.id) - - expect(reloaded_note.discussion_id).not_to be_nil - expect(reloaded_note.discussion_id).to match(/\A\h{40}\z/) - end + expect(subject[active_diff_note1.line_code].first.id).to eq(active_diff_note1.discussion_id) + expect(subject[active_diff_note3.line_code].first.id).to eq(active_diff_note3.discussion_id) end end @@ -388,6 +377,248 @@ describe Note, models: true do end end + describe '#can_be_discussion_note?' do + context 'for a note on a merge request' do + it 'returns true' do + note = build(:note_on_merge_request) + + expect(note.can_be_discussion_note?).to be_truthy + end + end + + context 'for a note on an issue' do + it 'returns true' do + note = build(:note_on_issue) + + expect(note.can_be_discussion_note?).to be_truthy + end + end + + context 'for a note on a commit' do + it 'returns true' do + note = build(:note_on_commit) + + expect(note.can_be_discussion_note?).to be_truthy + end + end + + context 'for a note on a snippet' do + it 'returns true' do + note = build(:note_on_project_snippet) + + expect(note.can_be_discussion_note?).to be_truthy + end + end + + context 'for a diff note on merge request' do + it 'returns false' do + note = build(:diff_note_on_merge_request) + + expect(note.can_be_discussion_note?).to be_falsey + end + end + + context 'for a diff note on commit' do + it 'returns false' do + note = build(:diff_note_on_commit) + + expect(note.can_be_discussion_note?).to be_falsey + end + end + + context 'for a discussion note' do + it 'returns false' do + note = build(:discussion_note_on_merge_request) + + expect(note.can_be_discussion_note?).to be_falsey + end + end + end + + describe '#discussion_class' do + let(:note) { build(:note_on_commit) } + let(:merge_request) { create(:merge_request) } + + context 'when the note is displayed out of context' do + it 'returns OutOfContextDiscussion' do + expect(note.discussion_class(merge_request)).to be(OutOfContextDiscussion) + end + end + + context 'when the note is displayed in the original context' do + it 'returns IndividualNoteDiscussion' do + expect(note.discussion_class(note.noteable)).to be(IndividualNoteDiscussion) + end + end + end + + describe "#discussion_id" do + let(:note) { create(:note_on_commit) } + + context "when it is newly created" do + it "has a discussion id" do + expect(note.discussion_id).not_to be_nil + expect(note.discussion_id).to match(/\A\h{40}\z/) + end + end + + context "when it didn't store a discussion id before" do + before do + note.update_column(:discussion_id, nil) + end + + it "has a discussion id" do + # The discussion_id is set in `after_initialize`, so `reload` won't work + reloaded_note = Note.find(note.id) + + expect(reloaded_note.discussion_id).not_to be_nil + expect(reloaded_note.discussion_id).to match(/\A\h{40}\z/) + end + end + + context 'when the note is displayed out of context' do + let(:merge_request) { create(:merge_request) } + + it 'overrides the discussion id' do + expect(note.discussion_id(merge_request)).not_to eq(note.discussion_id) + end + end + end + + describe '#to_discussion' do + subject { create(:discussion_note_on_merge_request) } + let!(:note2) { create(:discussion_note_on_merge_request, project: subject.project, noteable: subject.noteable, in_reply_to: subject) } + + it "returns a discussion with just this note" do + discussion = subject.to_discussion + + expect(discussion.id).to eq(subject.discussion_id) + expect(discussion.notes).to eq([subject]) + end + end + + describe "#discussion" do + let!(:note1) { create(:discussion_note_on_merge_request) } + let!(:note2) { create(:diff_note_on_merge_request, project: note1.project, noteable: note1.noteable) } + + context 'when the note is part of a discussion' do + subject { create(:discussion_note_on_merge_request, project: note1.project, noteable: note1.noteable, in_reply_to: note1) } + + it "returns the discussion this note is in" do + discussion = subject.discussion + + expect(discussion.id).to eq(subject.discussion_id) + expect(discussion.notes).to eq([note1, subject]) + end + end + + context 'when the note is not part of a discussion' do + subject { create(:note) } + + it "returns a discussion with just this note" do + discussion = subject.discussion + + expect(discussion.id).to eq(subject.discussion_id) + expect(discussion.notes).to eq([subject]) + end + end + end + + describe "#part_of_discussion?" do + context 'for a regular note' do + let(:note) { build(:note) } + + it 'returns false' do + expect(note.part_of_discussion?).to be_falsey + end + end + + context 'for a diff note' do + let(:note) { build(:diff_note_on_commit) } + + it 'returns true' do + expect(note.part_of_discussion?).to be_truthy + end + end + + context 'for a discussion note' do + let(:note) { build(:discussion_note_on_merge_request) } + + it 'returns true' do + expect(note.part_of_discussion?).to be_truthy + end + end + end + + describe '#in_reply_to?' do + context 'for a note' do + context 'when part of a discussion' do + subject { create(:discussion_note_on_issue) } + let(:note) { create(:discussion_note_on_issue, in_reply_to: subject) } + + it 'checks if the note is in reply to the other discussion' do + expect(subject).to receive(:in_reply_to?).with(note).and_call_original + expect(subject).to receive(:in_reply_to?).with(note.noteable).and_call_original + expect(subject).to receive(:in_reply_to?).with(note.to_discussion).and_call_original + + subject.in_reply_to?(note) + end + end + + context 'when not part of a discussion' do + subject { create(:note) } + let(:note) { create(:note, in_reply_to: subject) } + + it 'checks if the note is in reply to the other noteable' do + expect(subject).to receive(:in_reply_to?).with(note).and_call_original + expect(subject).to receive(:in_reply_to?).with(note.noteable).and_call_original + + subject.in_reply_to?(note) + end + end + end + + context 'for a discussion' do + context 'when part of the same discussion' do + subject { create(:diff_note_on_merge_request) } + let(:note) { create(:diff_note_on_merge_request, in_reply_to: subject) } + + it 'returns true' do + expect(subject.in_reply_to?(note.to_discussion)).to be_truthy + end + end + + context 'when not part of the same discussion' do + subject { create(:diff_note_on_merge_request) } + let(:note) { create(:diff_note_on_merge_request) } + + it 'returns false' do + expect(subject.in_reply_to?(note.to_discussion)).to be_falsey + end + end + end + + context 'for a noteable' do + context 'when a comment on the same noteable' do + subject { create(:note) } + let(:note) { create(:note, in_reply_to: subject) } + + it 'returns true' do + expect(subject.in_reply_to?(note.noteable)).to be_truthy + end + end + + context 'when not a comment on the same noteable' do + subject { create(:note) } + let(:note) { create(:note) } + + it 'returns false' do + expect(subject.in_reply_to?(note.noteable)).to be_falsey + end + end + end + end + describe 'expiring ETag cache' do let(:note) { build(:note_on_issue) } diff --git a/spec/models/project_services/chat_message/issue_message_spec.rb b/spec/models/project_services/chat_message/issue_message_spec.rb index 190ff4c535d..34e2d94b1ed 100644 --- a/spec/models/project_services/chat_message/issue_message_spec.rb +++ b/spec/models/project_services/chat_message/issue_message_spec.rb @@ -7,7 +7,8 @@ describe ChatMessage::IssueMessage, models: true do { user: { name: 'Test User', - username: 'test.user' + username: 'test.user', + avatar_url: 'http://someavatar.com' }, project_name: 'project_name', project_url: 'http://somewhere.com', @@ -25,43 +26,84 @@ describe ChatMessage::IssueMessage, models: true do } end - let(:color) { '#C95823' } + context 'without markdown' do + let(:color) { '#C95823' } - context '#initialize' do - before do - args[:object_attributes][:description] = nil + context '#initialize' do + before do + args[:object_attributes][:description] = nil + end + + it 'returns a non-null description' do + expect(subject.description).to eq('') + end end - it 'returns a non-null description' do - expect(subject.description).to eq('') + context 'open' do + it 'returns a message regarding opening of issues' do + expect(subject.pretext).to eq( + '[<http://somewhere.com|project_name>] Issue opened by test.user') + expect(subject.attachments).to eq([ + { + title: "#100 Issue title", + title_link: "http://url.com", + text: "issue description", + color: color, + } + ]) + end end - end - context 'open' do - it 'returns a message regarding opening of issues' do - expect(subject.pretext).to eq( - '[<http://somewhere.com|project_name>] Issue opened by test.user') - expect(subject.attachments).to eq([ - { - title: "#100 Issue title", - title_link: "http://url.com", - text: "issue description", - color: color, - } - ]) + context 'close' do + before do + args[:object_attributes][:action] = 'close' + args[:object_attributes][:state] = 'closed' + end + + it 'returns a message regarding closing of issues' do + expect(subject.pretext). to eq( + '[<http://somewhere.com|project_name>] Issue <http://url.com|#100 Issue title> closed by test.user') + expect(subject.attachments).to be_empty + end end end - context 'close' do + context 'with markdown' do before do - args[:object_attributes][:action] = 'close' - args[:object_attributes][:state] = 'closed' + args[:markdown] = true end - it 'returns a message regarding closing of issues' do - expect(subject.pretext). to eq( - '[<http://somewhere.com|project_name>] Issue <http://url.com|#100 Issue title> closed by test.user') - expect(subject.attachments).to be_empty + context 'open' do + it 'returns a message regarding opening of issues' do + expect(subject.pretext).to eq( + '[[project_name](http://somewhere.com)] Issue opened by test.user') + expect(subject.attachments).to eq('issue description') + expect(subject.activity).to eq({ + title: 'Issue opened by test.user', + subtitle: 'in [project_name](http://somewhere.com)', + text: '[#100 Issue title](http://url.com)', + image: 'http://someavatar.com' + }) + end + end + + context 'close' do + before do + args[:object_attributes][:action] = 'close' + args[:object_attributes][:state] = 'closed' + end + + it 'returns a message regarding closing of issues' do + expect(subject.pretext). to eq( + '[[project_name](http://somewhere.com)] Issue [#100 Issue title](http://url.com) closed by test.user') + expect(subject.attachments).to be_empty + expect(subject.activity).to eq({ + title: 'Issue closed by test.user', + subtitle: 'in [project_name](http://somewhere.com)', + text: '[#100 Issue title](http://url.com)', + image: 'http://someavatar.com' + }) + end end end end diff --git a/spec/models/project_services/chat_message/merge_message_spec.rb b/spec/models/project_services/chat_message/merge_message_spec.rb index cc154112e90..fa0a1f4a5b7 100644 --- a/spec/models/project_services/chat_message/merge_message_spec.rb +++ b/spec/models/project_services/chat_message/merge_message_spec.rb @@ -7,45 +7,84 @@ describe ChatMessage::MergeMessage, models: true do { user: { name: 'Test User', - username: 'test.user' + username: 'test.user', + avatar_url: 'http://someavatar.com' }, project_name: 'project_name', project_url: 'http://somewhere.com', object_attributes: { - title: "Issue title\nSecond line", + title: "Merge Request title\nSecond line", id: 10, iid: 100, assignee_id: 1, url: 'http://url.com', state: 'opened', - description: 'issue description', + description: 'merge request description', source_branch: 'source_branch', target_branch: 'target_branch', } } end - let(:color) { '#345' } + context 'without markdown' do + let(:color) { '#345' } - context 'open' do - it 'returns a message regarding opening of merge requests' do - expect(subject.pretext).to eq( - 'test.user opened <http://somewhere.com/merge_requests/100|merge request !100> '\ - 'in <http://somewhere.com|project_name>: *Issue title*') - expect(subject.attachments).to be_empty + context 'open' do + it 'returns a message regarding opening of merge requests' do + expect(subject.pretext).to eq( + 'test.user opened <http://somewhere.com/merge_requests/100|!100 *Merge Request title*> in <http://somewhere.com|project_name>: *Merge Request title*') + expect(subject.attachments).to be_empty + end + end + + context 'close' do + before do + args[:object_attributes][:state] = 'closed' + end + it 'returns a message regarding closing of merge requests' do + expect(subject.pretext).to eq( + 'test.user closed <http://somewhere.com/merge_requests/100|!100 *Merge Request title*> in <http://somewhere.com|project_name>: *Merge Request title*') + expect(subject.attachments).to be_empty + end end end - context 'close' do + context 'with markdown' do before do - args[:object_attributes][:state] = 'closed' + args[:markdown] = true + end + + context 'open' do + it 'returns a message regarding opening of merge requests' do + expect(subject.pretext).to eq( + 'test.user opened [!100 *Merge Request title*](http://somewhere.com/merge_requests/100) in [project_name](http://somewhere.com): *Merge Request title*') + expect(subject.attachments).to be_empty + expect(subject.activity).to eq({ + title: 'Merge Request opened by test.user', + subtitle: 'in [project_name](http://somewhere.com)', + text: '[!100 *Merge Request title*](http://somewhere.com/merge_requests/100)', + image: 'http://someavatar.com' + }) + end end - it 'returns a message regarding closing of merge requests' do - expect(subject.pretext).to eq( - 'test.user closed <http://somewhere.com/merge_requests/100|merge request !100> '\ - 'in <http://somewhere.com|project_name>: *Issue title*') - expect(subject.attachments).to be_empty + + context 'close' do + before do + args[:object_attributes][:state] = 'closed' + end + + it 'returns a message regarding closing of merge requests' do + expect(subject.pretext).to eq( + 'test.user closed [!100 *Merge Request title*](http://somewhere.com/merge_requests/100) in [project_name](http://somewhere.com): *Merge Request title*') + expect(subject.attachments).to be_empty + expect(subject.activity).to eq({ + title: 'Merge Request closed by test.user', + subtitle: 'in [project_name](http://somewhere.com)', + text: '[!100 *Merge Request title*](http://somewhere.com/merge_requests/100)', + image: 'http://someavatar.com' + }) + end end end end diff --git a/spec/models/project_services/chat_message/note_message_spec.rb b/spec/models/project_services/chat_message/note_message_spec.rb index da700a08e57..7cd9c61ee2b 100644 --- a/spec/models/project_services/chat_message/note_message_spec.rb +++ b/spec/models/project_services/chat_message/note_message_spec.rb @@ -1,130 +1,190 @@ require 'spec_helper' describe ChatMessage::NoteMessage, models: true do - let(:color) { '#345' } + subject { described_class.new(args) } - before do - @args = { - user: { - name: 'Test User', - username: 'test.user', - avatar_url: 'http://fakeavatar' - }, - project_name: 'project_name', - project_url: 'http://somewhere.com', - repository: { - name: 'project_name', - url: 'http://somewhere.com', - }, - object_attributes: { - id: 10, - note: 'comment on a commit', - url: 'http://url.com', - noteable_type: 'Commit' - } + let(:color) { '#345' } + let(:args) do + { + user: { + name: 'Test User', + username: 'test.user', + avatar_url: 'http://fakeavatar' + }, + project_name: 'project_name', + project_url: 'http://somewhere.com', + repository: { + name: 'project_name', + url: 'http://somewhere.com', + }, + object_attributes: { + id: 10, + note: 'comment on a commit', + url: 'http://url.com', + noteable_type: 'Commit' + } } end context 'commit notes' do before do - @args[:object_attributes][:note] = 'comment on a commit' - @args[:object_attributes][:noteable_type] = 'Commit' - @args[:commit] = { - id: '5f163b2b95e6f53cbd428f5f0b103702a52b9a23', - message: "Added a commit message\ndetails\n123\n" + args[:object_attributes][:note] = 'comment on a commit' + args[:object_attributes][:noteable_type] = 'Commit' + args[:commit] = { + id: '5f163b2b95e6f53cbd428f5f0b103702a52b9a23', + message: "Added a commit message\ndetails\n123\n" } end - it 'returns a message regarding notes on commits' do - message = described_class.new(@args) - expect(message.pretext).to eq("test.user <http://url.com|commented on " \ - "commit 5f163b2b> in <http://somewhere.com|project_name>: " \ - "*Added a commit message*") - expected_attachments = [ - { - text: "comment on a commit", - color: color, - } - ] - expect(message.attachments).to eq(expected_attachments) + context 'without markdown' do + it 'returns a message regarding notes on commits' do + expect(subject.pretext).to eq("test.user <http://url.com|commented on " \ + "commit 5f163b2b> in <http://somewhere.com|project_name>: " \ + "*Added a commit message*") + expect(subject.attachments).to eq([{ + text: 'comment on a commit', + color: color + }]) + end + end + + context 'with markdown' do + before do + args[:markdown] = true + end + + it 'returns a message regarding notes on commits' do + expect(subject.pretext).to eq( + 'test.user [commented on commit 5f163b2b](http://url.com) in [project_name](http://somewhere.com): *Added a commit message*' + ) + expect(subject.attachments).to eq('comment on a commit') + expect(subject.activity).to eq({ + title: 'test.user [commented on commit 5f163b2b](http://url.com)', + subtitle: 'in [project_name](http://somewhere.com)', + text: 'Added a commit message', + image: 'http://fakeavatar' + }) + end end end context 'merge request notes' do before do - @args[:object_attributes][:note] = 'comment on a merge request' - @args[:object_attributes][:noteable_type] = 'MergeRequest' - @args[:merge_request] = { - id: 1, - iid: 30, - title: "merge request title\ndetails\n" + args[:object_attributes][:note] = 'comment on a merge request' + args[:object_attributes][:noteable_type] = 'MergeRequest' + args[:merge_request] = { + id: 1, + iid: 30, + title: "merge request title\ndetails\n" } end - it 'returns a message regarding notes on a merge request' do - message = described_class.new(@args) - expect(message.pretext).to eq("test.user <http://url.com|commented on " \ - "merge request !30> in <http://somewhere.com|project_name>: " \ - "*merge request title*") - expected_attachments = [ - { - text: "comment on a merge request", - color: color, - } - ] - expect(message.attachments).to eq(expected_attachments) + context 'without markdown' do + it 'returns a message regarding notes on a merge request' do + expect(subject.pretext).to eq("test.user <http://url.com|commented on " \ + "merge request !30> in <http://somewhere.com|project_name>: " \ + "*merge request title*") + expect(subject.attachments).to eq([{ + text: 'comment on a merge request', + color: color + }]) + end + end + + context 'with markdown' do + before do + args[:markdown] = true + end + + it 'returns a message regarding notes on a merge request' do + expect(subject.pretext).to eq( + 'test.user [commented on merge request !30](http://url.com) in [project_name](http://somewhere.com): *merge request title*') + expect(subject.attachments).to eq('comment on a merge request') + expect(subject.activity).to eq({ + title: 'test.user [commented on merge request !30](http://url.com)', + subtitle: 'in [project_name](http://somewhere.com)', + text: 'merge request title', + image: 'http://fakeavatar' + }) + end end end context 'issue notes' do before do - @args[:object_attributes][:note] = 'comment on an issue' - @args[:object_attributes][:noteable_type] = 'Issue' - @args[:issue] = { - id: 1, - iid: 20, - title: "issue title\ndetails\n" + args[:object_attributes][:note] = 'comment on an issue' + args[:object_attributes][:noteable_type] = 'Issue' + args[:issue] = { + id: 1, + iid: 20, + title: "issue title\ndetails\n" } end - it 'returns a message regarding notes on an issue' do - message = described_class.new(@args) - expect(message.pretext).to eq( - "test.user <http://url.com|commented on " \ - "issue #20> in <http://somewhere.com|project_name>: " \ - "*issue title*") - expected_attachments = [ - { - text: "comment on an issue", - color: color, - } - ] - expect(message.attachments).to eq(expected_attachments) + context 'without markdown' do + it 'returns a message regarding notes on an issue' do + expect(subject.pretext).to eq( + "test.user <http://url.com|commented on " \ + "issue #20> in <http://somewhere.com|project_name>: " \ + "*issue title*") + expect(subject.attachments).to eq([{ + text: 'comment on an issue', + color: color + }]) + end + end + + context 'with markdown' do + before do + args[:markdown] = true + end + + it 'returns a message regarding notes on an issue' do + expect(subject.pretext).to eq( + 'test.user [commented on issue #20](http://url.com) in [project_name](http://somewhere.com): *issue title*') + expect(subject.attachments).to eq('comment on an issue') + expect(subject.activity).to eq({ + title: 'test.user [commented on issue #20](http://url.com)', + subtitle: 'in [project_name](http://somewhere.com)', + text: 'issue title', + image: 'http://fakeavatar' + }) + end end end context 'project snippet notes' do before do - @args[:object_attributes][:note] = 'comment on a snippet' - @args[:object_attributes][:noteable_type] = 'Snippet' - @args[:snippet] = { - id: 5, - title: "snippet title\ndetails\n" + args[:object_attributes][:note] = 'comment on a snippet' + args[:object_attributes][:noteable_type] = 'Snippet' + args[:snippet] = { + id: 5, + title: "snippet title\ndetails\n" } end - it 'returns a message regarding notes on a project snippet' do - message = described_class.new(@args) - expect(message.pretext).to eq("test.user <http://url.com|commented on " \ - "snippet #5> in <http://somewhere.com|project_name>: " \ - "*snippet title*") - expected_attachments = [ - { - text: "comment on a snippet", - color: color, - } - ] - expect(message.attachments).to eq(expected_attachments) + context 'without markdown' do + it 'returns a message regarding notes on a project snippet' do + expect(subject.pretext).to eq("test.user <http://url.com|commented on " \ + "snippet $5> in <http://somewhere.com|project_name>: " \ + "*snippet title*") + expect(subject.attachments).to eq([{ + text: 'comment on a snippet', + color: color + }]) + end + end + + context 'with markdown' do + before do + args[:markdown] = true + end + + it 'returns a message regarding notes on a project snippet' do + expect(subject.pretext).to eq( + 'test.user [commented on snippet $5](http://url.com) in [project_name](http://somewhere.com): *snippet title*') + expect(subject.attachments).to eq('comment on a snippet') + end end end end diff --git a/spec/models/project_services/chat_message/pipeline_message_spec.rb b/spec/models/project_services/chat_message/pipeline_message_spec.rb index bf2a9616455..ec5c6c5e0ed 100644 --- a/spec/models/project_services/chat_message/pipeline_message_spec.rb +++ b/spec/models/project_services/chat_message/pipeline_message_spec.rb @@ -2,8 +2,8 @@ require 'spec_helper' describe ChatMessage::PipelineMessage do subject { described_class.new(args) } - let(:user) { { name: 'hacker' } } + let(:user) { { name: 'hacker' } } let(:args) do { object_attributes: { @@ -14,54 +14,122 @@ describe ChatMessage::PipelineMessage do status: status, duration: duration }, - project: { path_with_namespace: 'project_name', - web_url: 'http://example.gitlab.com' }, + project: { + path_with_namespace: 'project_name', + web_url: 'http://example.gitlab.com' + }, user: user } end - let(:message) { build_message } + context 'without markdown' do + context 'pipeline succeeded' do + let(:status) { 'success' } + let(:color) { 'good' } + let(:duration) { 10 } + let(:message) { build_message('passed') } + + it 'returns a message with information about succeeded build' do + expect(subject.pretext).to be_empty + expect(subject.fallback).to eq(message) + expect(subject.attachments).to eq([text: message, color: color]) + end + end - context 'pipeline succeeded' do - let(:status) { 'success' } - let(:color) { 'good' } - let(:duration) { 10 } - let(:message) { build_message('passed') } + context 'pipeline failed' do + let(:status) { 'failed' } + let(:color) { 'danger' } + let(:duration) { 10 } + let(:message) { build_message } - it 'returns a message with information about succeeded build' do - verify_message + it 'returns a message with information about failed build' do + expect(subject.pretext).to be_empty + expect(subject.fallback).to eq(message) + expect(subject.attachments).to eq([text: message, color: color]) + end + + context 'when triggered by API therefore lacking user' do + let(:user) { nil } + let(:message) { build_message(status, 'API') } + + it 'returns a message stating it is by API' do + expect(subject.pretext).to be_empty + expect(subject.fallback).to eq(message) + expect(subject.attachments).to eq([text: message, color: color]) + end + end end - end - context 'pipeline failed' do - let(:status) { 'failed' } - let(:color) { 'danger' } - let(:duration) { 10 } + def build_message(status_text = status, name = user[:name]) + "<http://example.gitlab.com|project_name>:" \ + " Pipeline <http://example.gitlab.com/pipelines/123|#123>" \ + " of <http://example.gitlab.com/commits/develop|develop> branch" \ + " by #{name} #{status_text} in #{duration} #{'second'.pluralize(duration)}" + end + end - it 'returns a message with information about failed build' do - verify_message + context 'with markdown' do + before do + args[:markdown] = true end - context 'when triggered by API therefore lacking user' do - let(:user) { nil } - let(:message) { build_message(status, 'API') } + context 'pipeline succeeded' do + let(:status) { 'success' } + let(:color) { 'good' } + let(:duration) { 10 } + let(:message) { build_markdown_message('passed') } - it 'returns a message stating it is by API' do - verify_message + it 'returns a message with information about succeeded build' do + expect(subject.pretext).to be_empty + expect(subject.attachments).to eq(message) + expect(subject.activity).to eq({ + title: 'Pipeline [#123](http://example.gitlab.com/pipelines/123) of [develop](http://example.gitlab.com/commits/develop) branch by hacker passed', + subtitle: 'in [project_name](http://example.gitlab.com)', + text: 'in 10 seconds', + image: '' + }) end end - end - def verify_message - expect(subject.pretext).to be_empty - expect(subject.fallback).to eq(message) - expect(subject.attachments).to eq([text: message, color: color]) - end + context 'pipeline failed' do + let(:status) { 'failed' } + let(:color) { 'danger' } + let(:duration) { 10 } + let(:message) { build_markdown_message } + + it 'returns a message with information about failed build' do + expect(subject.pretext).to be_empty + expect(subject.attachments).to eq(message) + expect(subject.activity).to eq({ + title: 'Pipeline [#123](http://example.gitlab.com/pipelines/123) of [develop](http://example.gitlab.com/commits/develop) branch by hacker failed', + subtitle: 'in [project_name](http://example.gitlab.com)', + text: 'in 10 seconds', + image: '' + }) + end - def build_message(status_text = status, name = user[:name]) - "<http://example.gitlab.com|project_name>:" \ - " Pipeline <http://example.gitlab.com/pipelines/123|#123>" \ - " of <http://example.gitlab.com/commits/develop|develop> branch" \ - " by #{name} #{status_text} in #{duration} #{'second'.pluralize(duration)}" + context 'when triggered by API therefore lacking user' do + let(:user) { nil } + let(:message) { build_markdown_message(status, 'API') } + + it 'returns a message stating it is by API' do + expect(subject.pretext).to be_empty + expect(subject.attachments).to eq(message) + expect(subject.activity).to eq({ + title: 'Pipeline [#123](http://example.gitlab.com/pipelines/123) of [develop](http://example.gitlab.com/commits/develop) branch by API failed', + subtitle: 'in [project_name](http://example.gitlab.com)', + text: 'in 10 seconds', + image: '' + }) + end + end + end + + def build_markdown_message(status_text = status, name = user[:name]) + "[project_name](http://example.gitlab.com):" \ + " Pipeline [#123](http://example.gitlab.com/pipelines/123)" \ + " of [develop](http://example.gitlab.com/commits/develop)" \ + " branch by #{name} #{status_text} in #{duration} #{'second'.pluralize(duration)}" + end end end diff --git a/spec/models/project_services/chat_message/push_message_spec.rb b/spec/models/project_services/chat_message/push_message_spec.rb index 24928873bad..63eb078c44e 100644 --- a/spec/models/project_services/chat_message/push_message_spec.rb +++ b/spec/models/project_services/chat_message/push_message_spec.rb @@ -10,6 +10,7 @@ describe ChatMessage::PushMessage, models: true do project_name: 'project_name', ref: 'refs/heads/master', user_name: 'test.user', + user_avatar: 'http://someavatar.com', project_url: 'http://url.com' } end @@ -24,18 +25,36 @@ describe ChatMessage::PushMessage, models: true do ] end - it 'returns a message regarding pushes' do - expect(subject.pretext).to eq( - 'test.user pushed to branch <http://url.com/commits/master|master> of '\ - '<http://url.com|project_name> (<http://url.com/compare/before...after|Compare changes>)' - ) - expect(subject.attachments).to eq([ - { - text: "<http://url1.com|abcdefgh>: message1 - author1\n"\ - "<http://url2.com|12345678>: message2 - author2", + context 'without markdown' do + it 'returns a message regarding pushes' do + expect(subject.pretext).to eq( + 'test.user pushed to branch <http://url.com/commits/master|master> of '\ + '<http://url.com|project_name> (<http://url.com/compare/before...after|Compare changes>)') + expect(subject.attachments).to eq([{ + text: "<http://url1.com|abcdefgh>: message1 - author1\n\n"\ + "<http://url2.com|12345678>: message2 - author2", color: color, - } - ]) + }]) + end + end + + context 'with markdown' do + before do + args[:markdown] = true + end + + it 'returns a message regarding pushes' do + expect(subject.pretext).to eq( + 'test.user pushed to branch [master](http://url.com/commits/master) of [project_name](http://url.com) ([Compare changes](http://url.com/compare/before...after))') + expect(subject.attachments).to eq( + "[abcdefgh](http://url1.com): message1 - author1\n\n[12345678](http://url2.com): message2 - author2") + expect(subject.activity).to eq({ + title: 'test.user pushed to branch', + subtitle: 'in [project_name](http://url.com)', + text: '[Compare changes](http://url.com/compare/before...after)', + image: 'http://someavatar.com' + }) + end end end @@ -47,15 +66,36 @@ describe ChatMessage::PushMessage, models: true do project_name: 'project_name', ref: 'refs/tags/new_tag', user_name: 'test.user', + user_avatar: 'http://someavatar.com', project_url: 'http://url.com' } end - it 'returns a message regarding pushes' do - expect(subject.pretext).to eq('test.user pushed new tag ' \ - '<http://url.com/commits/new_tag|new_tag> to ' \ - '<http://url.com|project_name>') - expect(subject.attachments).to be_empty + context 'without markdown' do + it 'returns a message regarding pushes' do + expect(subject.pretext).to eq('test.user pushed new tag ' \ + '<http://url.com/commits/new_tag|new_tag> to ' \ + '<http://url.com|project_name>') + expect(subject.attachments).to be_empty + end + end + + context 'with markdown' do + before do + args[:markdown] = true + end + + it 'returns a message regarding pushes' do + expect(subject.pretext).to eq( + 'test.user pushed new tag [new_tag](http://url.com/commits/new_tag) to [project_name](http://url.com)') + expect(subject.attachments).to be_empty + expect(subject.activity).to eq({ + title: 'test.user created tag', + subtitle: 'in [project_name](http://url.com)', + text: '[Compare changes](http://url.com/compare/0000000000000000000000000000000000000000...after)', + image: 'http://someavatar.com' + }) + end end end @@ -64,12 +104,31 @@ describe ChatMessage::PushMessage, models: true do args[:before] = Gitlab::Git::BLANK_SHA end - it 'returns a message regarding a new branch' do - expect(subject.pretext).to eq( - 'test.user pushed new branch <http://url.com/commits/master|master> to '\ - '<http://url.com|project_name>' - ) - expect(subject.attachments).to be_empty + context 'without markdown' do + it 'returns a message regarding a new branch' do + expect(subject.pretext).to eq( + 'test.user pushed new branch <http://url.com/commits/master|master> to '\ + '<http://url.com|project_name>') + expect(subject.attachments).to be_empty + end + end + + context 'with markdown' do + before do + args[:markdown] = true + end + + it 'returns a message regarding a new branch' do + expect(subject.pretext).to eq( + 'test.user pushed new branch [master](http://url.com/commits/master) to [project_name](http://url.com)') + expect(subject.attachments).to be_empty + expect(subject.activity).to eq({ + title: 'test.user created branch', + subtitle: 'in [project_name](http://url.com)', + text: '[Compare changes](http://url.com/compare/0000000000000000000000000000000000000000...after)', + image: 'http://someavatar.com' + }) + end end end @@ -78,11 +137,30 @@ describe ChatMessage::PushMessage, models: true do args[:after] = Gitlab::Git::BLANK_SHA end - it 'returns a message regarding a removed branch' do - expect(subject.pretext).to eq( - 'test.user removed branch master from <http://url.com|project_name>' - ) - expect(subject.attachments).to be_empty + context 'without markdown' do + it 'returns a message regarding a removed branch' do + expect(subject.pretext).to eq( + 'test.user removed branch master from <http://url.com|project_name>') + expect(subject.attachments).to be_empty + end + end + + context 'with markdown' do + before do + args[:markdown] = true + end + + it 'returns a message regarding a removed branch' do + expect(subject.pretext).to eq( + 'test.user removed branch master from [project_name](http://url.com)') + expect(subject.attachments).to be_empty + expect(subject.activity).to eq({ + title: 'test.user removed branch', + subtitle: 'in [project_name](http://url.com)', + text: '[Compare changes](http://url.com/compare/before...0000000000000000000000000000000000000000)', + image: 'http://someavatar.com' + }) + end end end end diff --git a/spec/models/project_services/chat_message/wiki_page_message_spec.rb b/spec/models/project_services/chat_message/wiki_page_message_spec.rb index a2ad61e38e7..0df7db2abc2 100644 --- a/spec/models/project_services/chat_message/wiki_page_message_spec.rb +++ b/spec/models/project_services/chat_message/wiki_page_message_spec.rb @@ -7,7 +7,8 @@ describe ChatMessage::WikiPageMessage, models: true do { user: { name: 'Test User', - username: 'test.user' + username: 'test.user', + avatar_url: 'http://someavatar.com' }, project_name: 'project_name', project_url: 'http://somewhere.com', @@ -19,54 +20,128 @@ describe ChatMessage::WikiPageMessage, models: true do } end - describe '#pretext' do - context 'when :action == "create"' do - before { args[:object_attributes][:action] = 'create' } + context 'without markdown' do + describe '#pretext' do + context 'when :action == "create"' do + before { args[:object_attributes][:action] = 'create' } - it 'returns a message that a new wiki page was created' do - expect(subject.pretext).to eq( - 'test.user created <http://url.com|wiki page> in <http://somewhere.com|project_name>: '\ - '*Wiki page title*') + it 'returns a message that a new wiki page was created' do + expect(subject.pretext).to eq( + 'test.user created <http://url.com|wiki page> in <http://somewhere.com|project_name>: '\ + '*Wiki page title*') + end + end + + context 'when :action == "update"' do + before { args[:object_attributes][:action] = 'update' } + + it 'returns a message that a wiki page was updated' do + expect(subject.pretext).to eq( + 'test.user edited <http://url.com|wiki page> in <http://somewhere.com|project_name>: '\ + '*Wiki page title*') + end end end - context 'when :action == "update"' do - before { args[:object_attributes][:action] = 'update' } + describe '#attachments' do + let(:color) { '#345' } - it 'returns a message that a wiki page was updated' do - expect(subject.pretext).to eq( - 'test.user edited <http://url.com|wiki page> in <http://somewhere.com|project_name>: '\ - '*Wiki page title*') + context 'when :action == "create"' do + before { args[:object_attributes][:action] = 'create' } + + it 'returns the attachment for a new wiki page' do + expect(subject.attachments).to eq([ + { + text: "Wiki page description", + color: color, + } + ]) + end + end + + context 'when :action == "update"' do + before { args[:object_attributes][:action] = 'update' } + + it 'returns the attachment for an updated wiki page' do + expect(subject.attachments).to eq([ + { + text: "Wiki page description", + color: color, + } + ]) + end end end end - describe '#attachments' do - let(:color) { '#345' } + context 'with markdown' do + before do + args[:markdown] = true + end + + describe '#pretext' do + context 'when :action == "create"' do + before { args[:object_attributes][:action] = 'create' } + + it 'returns a message that a new wiki page was created' do + expect(subject.pretext).to eq( + 'test.user created [wiki page](http://url.com) in [project_name](http://somewhere.com): *Wiki page title*') + end + end - context 'when :action == "create"' do - before { args[:object_attributes][:action] = 'create' } + context 'when :action == "update"' do + before { args[:object_attributes][:action] = 'update' } - it 'returns the attachment for a new wiki page' do - expect(subject.attachments).to eq([ - { - text: "Wiki page description", - color: color, - } - ]) + it 'returns a message that a wiki page was updated' do + expect(subject.pretext).to eq( + 'test.user edited [wiki page](http://url.com) in [project_name](http://somewhere.com): *Wiki page title*') + end end end - context 'when :action == "update"' do - before { args[:object_attributes][:action] = 'update' } + describe '#attachments' do + context 'when :action == "create"' do + before { args[:object_attributes][:action] = 'create' } + + it 'returns the attachment for a new wiki page' do + expect(subject.attachments).to eq('Wiki page description') + end + end + + context 'when :action == "update"' do + before { args[:object_attributes][:action] = 'update' } + + it 'returns the attachment for an updated wiki page' do + expect(subject.attachments).to eq('Wiki page description') + end + end + end + + describe '#activity' do + context 'when :action == "create"' do + before { args[:object_attributes][:action] = 'create' } + + it 'returns the attachment for a new wiki page' do + expect(subject.activity).to eq({ + title: 'test.user created [wiki page](http://url.com)', + subtitle: 'in [project_name](http://somewhere.com)', + text: 'Wiki page title', + image: 'http://someavatar.com' + }) + end + end + + context 'when :action == "update"' do + before { args[:object_attributes][:action] = 'update' } - it 'returns the attachment for an updated wiki page' do - expect(subject.attachments).to eq([ - { - text: "Wiki page description", - color: color, - } - ]) + it 'returns the attachment for an updated wiki page' do + expect(subject.activity).to eq({ + title: 'test.user edited [wiki page](http://url.com)', + subtitle: 'in [project_name](http://somewhere.com)', + text: 'Wiki page title', + image: 'http://someavatar.com' + }) + end end end end diff --git a/spec/models/project_services/microsoft_teams_service_spec.rb b/spec/models/project_services/microsoft_teams_service_spec.rb new file mode 100644 index 00000000000..facc034f69c --- /dev/null +++ b/spec/models/project_services/microsoft_teams_service_spec.rb @@ -0,0 +1,277 @@ +require 'spec_helper' + +describe MicrosoftTeamsService, models: true do + let(:chat_service) { described_class.new } + let(:webhook_url) { 'https://example.gitlab.com/' } + + describe "Associations" do + it { is_expected.to belong_to :project } + it { is_expected.to have_one :service_hook } + end + + describe 'Validations' do + context 'when service is active' do + before { subject.active = true } + + it { is_expected.to validate_presence_of(:webhook) } + it_behaves_like 'issue tracker service URL attribute', :webhook + end + + context 'when service is inactive' do + before { subject.active = false } + + it { is_expected.not_to validate_presence_of(:webhook) } + end + end + + describe "#execute" do + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + + before do + allow(chat_service).to receive_messages( + project: project, + project_id: project.id, + service_hook: true, + webhook: webhook_url + ) + + WebMock.stub_request(:post, webhook_url) + end + + context 'with push events' do + let(:push_sample_data) do + Gitlab::DataBuilder::Push.build_sample(project, user) + end + + it "calls Microsoft Teams API for push events" do + chat_service.execute(push_sample_data) + + expect(WebMock).to have_requested(:post, webhook_url).once + end + + it 'specifies the webhook when it is configured' do + expect(MicrosoftTeams::Notifier).to receive(:new).with(webhook_url).and_return(double(:microsoft_teams_service).as_null_object) + + chat_service.execute(push_sample_data) + end + end + + context 'with issue events' do + let(:opts) { { title: 'Awesome issue', description: 'please fix' } } + let(:issues_sample_data) do + service = Issues::CreateService.new(project, user, opts) + issue = service.execute + service.hook_data(issue, 'open') + end + + it "calls Microsoft Teams API" do + chat_service.execute(issues_sample_data) + + expect(WebMock).to have_requested(:post, webhook_url).once + end + end + + context 'with merge events' do + let(:opts) do + { + title: 'Awesome merge_request', + description: 'please fix', + source_branch: 'feature', + target_branch: 'master' + } + end + + let(:merge_sample_data) do + service = MergeRequests::CreateService.new(project, user, opts) + merge_request = service.execute + service.hook_data(merge_request, 'open') + end + + it "calls Microsoft Teams API" do + chat_service.execute(merge_sample_data) + + expect(WebMock).to have_requested(:post, webhook_url).once + end + end + + context 'with wiki page events' do + let(:opts) do + { + title: "Awesome wiki_page", + content: "Some text describing some thing or another", + format: "md", + message: "user created page: Awesome wiki_page" + } + end + + let(:wiki_page_sample_data) do + service = WikiPages::CreateService.new(project, user, opts) + wiki_page = service.execute + service.hook_data(wiki_page, 'create') + end + + it "calls Microsoft Teams API" do + chat_service.execute(wiki_page_sample_data) + + expect(WebMock).to have_requested(:post, webhook_url).once + end + end + end + + describe "Note events" do + let(:user) { create(:user) } + let(:project) { create(:project, :repository, creator: user) } + + before do + allow(chat_service).to receive_messages( + project: project, + project_id: project.id, + service_hook: true, + webhook: webhook_url + ) + + WebMock.stub_request(:post, webhook_url) + end + + context 'when commit comment event executed' do + let(:commit_note) do + create(:note_on_commit, author: user, + project: project, + commit_id: project.repository.commit.id, + note: 'a comment on a commit') + end + + it "calls Microsoft Teams API for commit comment events" do + data = Gitlab::DataBuilder::Note.build(commit_note, user) + + chat_service.execute(data) + + expect(WebMock).to have_requested(:post, webhook_url).once + end + end + + context 'when merge request comment event executed' do + let(:merge_request_note) do + create(:note_on_merge_request, project: project, + note: "merge request note") + end + + it "calls Microsoft Teams API for merge request comment events" do + data = Gitlab::DataBuilder::Note.build(merge_request_note, user) + + chat_service.execute(data) + + expect(WebMock).to have_requested(:post, webhook_url).once + end + end + + context 'when issue comment event executed' do + let(:issue_note) do + create(:note_on_issue, project: project, note: "issue note") + end + + it "calls Microsoft Teams API for issue comment events" do + data = Gitlab::DataBuilder::Note.build(issue_note, user) + + chat_service.execute(data) + + expect(WebMock).to have_requested(:post, webhook_url).once + end + end + + context 'when snippet comment event executed' do + let(:snippet_note) do + create(:note_on_project_snippet, project: project, + note: "snippet note") + end + + it "calls Microsoft Teams API for snippet comment events" do + data = Gitlab::DataBuilder::Note.build(snippet_note, user) + + chat_service.execute(data) + + expect(WebMock).to have_requested(:post, webhook_url).once + end + end + end + + describe 'Pipeline events' do + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + + let(:pipeline) do + create(:ci_pipeline, + project: project, status: status, + sha: project.commit.sha, ref: project.default_branch) + end + + before do + allow(chat_service).to receive_messages( + project: project, + service_hook: true, + webhook: webhook_url + ) + end + + shared_examples 'call Microsoft Teams API' do + before do + WebMock.stub_request(:post, webhook_url) + end + + it 'calls Microsoft Teams API for pipeline events' do + data = Gitlab::DataBuilder::Pipeline.build(pipeline) + + chat_service.execute(data) + + expect(WebMock).to have_requested(:post, webhook_url).once + end + end + + context 'with failed pipeline' do + let(:status) { 'failed' } + + it_behaves_like 'call Microsoft Teams API' + end + + context 'with succeeded pipeline' do + let(:status) { 'success' } + + context 'with default to notify_only_broken_pipelines' do + it 'does not call Microsoft Teams API for pipeline events' do + data = Gitlab::DataBuilder::Pipeline.build(pipeline) + result = chat_service.execute(data) + + expect(result).to be_falsy + end + end + + context 'with setting notify_only_broken_pipelines to false' do + before do + chat_service.notify_only_broken_pipelines = false + end + + it_behaves_like 'call Microsoft Teams API' + end + end + + context 'only notify for the default branch' do + context 'when enabled' do + let(:pipeline) do + create(:ci_pipeline, project: project, status: 'failed', ref: 'not-the-default-branch') + end + + before do + chat_service.notify_only_default_branch = true + end + + it 'does not call the Microsoft Teams API for pipeline events' do + data = Gitlab::DataBuilder::Pipeline.build(pipeline) + result = chat_service.execute(data) + + expect(result).to be_falsy + end + end + end + end +end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 59a2560ca06..e5dd4bcb0d8 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -22,6 +22,7 @@ describe Project, models: true do it { is_expected.to have_many(:protected_branches).dependent(:destroy) } it { is_expected.to have_one(:forked_project_link).dependent(:destroy) } it { is_expected.to have_one(:slack_service).dependent(:destroy) } + it { is_expected.to have_one(:microsoft_teams_service).dependent(:destroy) } it { is_expected.to have_one(:mattermost_service).dependent(:destroy) } it { is_expected.to have_one(:pushover_service).dependent(:destroy) } it { is_expected.to have_one(:asana_service).dependent(:destroy) } @@ -1157,11 +1158,12 @@ describe Project, models: true do # Project#gitlab_shell returns a new instance of Gitlab::Shell on every # call. This makes testing a bit easier. allow(project).to receive(:gitlab_shell).and_return(gitlab_shell) - allow(project).to receive(:previous_changes).and_return('path' => ['foo']) end it 'renames a repository' do + stub_container_registry_config(enabled: false) + expect(gitlab_shell).to receive(:mv_repository). ordered. with(project.repository_storage_path, "#{project.namespace.full_path}/foo", "#{project.full_path}"). @@ -1185,10 +1187,13 @@ describe Project, models: true do project.rename_repo end - context 'container registry with tags' do + context 'container registry with images' do + let(:container_repository) { create(:container_repository) } + before do stub_container_registry_config(enabled: true) - stub_container_registry_tags('tag') + stub_container_registry_tags(repository: :any, tags: ['tag']) + project.container_repositories << container_repository end subject { project.rename_repo } @@ -1386,38 +1391,17 @@ describe Project, models: true do end end - describe '#container_registry_path_with_namespace' do - let(:project) { create(:empty_project, path: 'PROJECT') } - - subject { project.container_registry_path_with_namespace } - - it { is_expected.not_to eq(project.path_with_namespace) } - it { is_expected.to eq(project.path_with_namespace.downcase) } - end - - describe '#container_registry_repository' do - let(:project) { create(:empty_project) } - - before { stub_container_registry_config(enabled: true) } - - subject { project.container_registry_repository } - - it { is_expected.not_to be_nil } - end - - describe '#container_registry_repository_url' do + describe '#container_registry_url' do let(:project) { create(:empty_project) } - subject { project.container_registry_repository_url } + subject { project.container_registry_url } before { stub_container_registry_config(**registry_settings) } context 'for enabled registry' do let(:registry_settings) do - { - enabled: true, - host_port: 'example.com', - } + { enabled: true, + host_port: 'example.com' } end it { is_expected.not_to be_nil } @@ -1425,9 +1409,7 @@ describe Project, models: true do context 'for disabled registry' do let(:registry_settings) do - { - enabled: false - } + { enabled: false } end it { is_expected.to be_nil } @@ -1437,28 +1419,60 @@ describe Project, models: true do describe '#has_container_registry_tags?' do let(:project) { create(:empty_project) } - subject { project.has_container_registry_tags? } - - context 'for enabled registry' do + context 'when container registry is enabled' do before { stub_container_registry_config(enabled: true) } - context 'with tags' do - before { stub_container_registry_tags('test', 'test2') } + context 'when tags are present for multi-level registries' do + before do + create(:container_repository, project: project, name: 'image') + + stub_container_registry_tags(repository: /image/, + tags: %w[latest rc1]) + end - it { is_expected.to be_truthy } + it 'should have image tags' do + expect(project).to have_container_registry_tags + end end - context 'when no tags' do - before { stub_container_registry_tags } + context 'when tags are present for root repository' do + before do + stub_container_registry_tags(repository: project.full_path, + tags: %w[latest rc1 pre1]) + end - it { is_expected.to be_falsey } + it 'should have image tags' do + expect(project).to have_container_registry_tags + end + end + + context 'when there are no tags at all' do + before do + stub_container_registry_tags(repository: :any, tags: []) + end + + it 'should not have image tags' do + expect(project).not_to have_container_registry_tags + end end end - context 'for disabled registry' do + context 'when container registry is disabled' do before { stub_container_registry_config(enabled: false) } - it { is_expected.to be_falsey } + it 'should not have image tags' do + expect(project).not_to have_container_registry_tags + end + + it 'should not check root repository tags' do + expect(project).not_to receive(:full_path) + expect(project).not_to have_container_registry_tags + end + + it 'should iterate through container repositories' do + expect(project).to receive(:container_repositories) + expect(project).not_to have_container_registry_tags + end end end diff --git a/spec/models/sent_notification_spec.rb b/spec/models/sent_notification_spec.rb new file mode 100644 index 00000000000..6b7eef388be --- /dev/null +++ b/spec/models/sent_notification_spec.rb @@ -0,0 +1,166 @@ +require 'spec_helper' + +describe SentNotification, model: true do + describe 'validation' do + describe 'note validity' do + context "when the project doesn't match the noteable's project" do + subject { build(:sent_notification, noteable: create(:issue)) } + + it "is invalid" do + expect(subject).not_to be_valid + end + end + + context "when the project doesn't match the discussion project" do + let(:discussion_id) { create(:note).discussion_id } + subject { build(:sent_notification, in_reply_to_discussion_id: discussion_id) } + + it "is invalid" do + expect(subject).not_to be_valid + end + end + + context "when the noteable project and discussion project match" do + let(:project) { create(:project) } + let(:issue) { create(:issue, project: project) } + let(:discussion_id) { create(:note, project: project, noteable: issue).discussion_id } + subject { build(:sent_notification, project: project, noteable: issue, in_reply_to_discussion_id: discussion_id) } + + it "is valid" do + expect(subject).to be_valid + end + end + end + end + + describe '.record' do + let(:user) { create(:user) } + let(:issue) { create(:issue) } + + it 'creates a new SentNotification' do + expect { described_class.record(issue, user.id) }.to change { SentNotification.count }.by(1) + end + end + + describe '.record_note' do + let(:user) { create(:user) } + let(:note) { create(:diff_note_on_merge_request) } + + it 'creates a new SentNotification' do + expect { described_class.record_note(note, user.id) }.to change { SentNotification.count }.by(1) + end + end + + describe '#create_reply' do + context 'for issue' do + let(:issue) { create(:issue) } + subject { described_class.record(issue, issue.author.id) } + + it 'creates a comment on the issue' do + note = subject.create_reply('Test') + expect(note.in_reply_to?(issue)).to be_truthy + end + end + + context 'for issue comment' do + let(:note) { create(:note_on_issue) } + subject { described_class.record_note(note, note.author.id) } + + it 'creates a comment on the issue' do + new_note = subject.create_reply('Test') + expect(new_note.in_reply_to?(note)).to be_truthy + end + end + + context 'for issue discussion' do + let(:note) { create(:discussion_note_on_issue) } + subject { described_class.record_note(note, note.author.id) } + + it 'creates a reply on the discussion' do + new_note = subject.create_reply('Test') + expect(new_note.in_reply_to?(note)).to be_truthy + end + end + + context 'for merge request' do + let(:merge_request) { create(:merge_request) } + subject { described_class.record(merge_request, merge_request.author.id) } + + it 'creates a comment on the merge_request' do + note = subject.create_reply('Test') + expect(note.in_reply_to?(merge_request)).to be_truthy + end + end + + context 'for merge request comment' do + let(:note) { create(:note_on_merge_request) } + subject { described_class.record_note(note, note.author.id) } + + it 'creates a comment on the merge request' do + new_note = subject.create_reply('Test') + expect(new_note.in_reply_to?(note)).to be_truthy + end + end + + context 'for merge request diff discussion' do + let(:note) { create(:diff_note_on_merge_request) } + subject { described_class.record_note(note, note.author.id) } + + it 'creates a reply on the discussion' do + new_note = subject.create_reply('Test') + expect(new_note.in_reply_to?(note)).to be_truthy + end + end + + context 'for merge request non-diff discussion' do + let(:note) { create(:discussion_note_on_merge_request) } + subject { described_class.record_note(note, note.author.id) } + + it 'creates a reply on the discussion' do + new_note = subject.create_reply('Test') + expect(new_note.in_reply_to?(note)).to be_truthy + end + end + + context 'for commit' do + let(:project) { create(:project) } + let(:commit) { project.commit } + subject { described_class.record(commit, project.creator.id) } + + it 'creates a comment on the commit' do + note = subject.create_reply('Test') + expect(note.in_reply_to?(commit)).to be_truthy + end + end + + context 'for commit comment' do + let(:note) { create(:note_on_commit) } + subject { described_class.record_note(note, note.author.id) } + + it 'creates a comment on the commit' do + new_note = subject.create_reply('Test') + expect(new_note.in_reply_to?(note)).to be_truthy + end + end + + context 'for commit diff discussion' do + let(:note) { create(:diff_note_on_commit) } + subject { described_class.record_note(note, note.author.id) } + + it 'creates a reply on the discussion' do + new_note = subject.create_reply('Test') + expect(new_note.in_reply_to?(note)).to be_truthy + end + end + + context 'for commit non-diff discussion' do + let(:note) { create(:discussion_note_on_commit) } + subject { described_class.record_note(note, note.author.id) } + + it 'creates a reply on the discussion' do + new_note = subject.create_reply('Test') + expect(new_note.in_reply_to?(note)).to be_truthy + end + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index a9e37be1157..6f7b9c2388a 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -28,7 +28,6 @@ describe User, models: true do it { is_expected.to have_many(:merge_requests).dependent(:destroy) } it { is_expected.to have_many(:assigned_merge_requests).dependent(:nullify) } it { is_expected.to have_many(:identities).dependent(:destroy) } - it { is_expected.to have_one(:abuse_report) } it { is_expected.to have_many(:spam_logs).dependent(:destroy) } it { is_expected.to have_many(:todos).dependent(:destroy) } it { is_expected.to have_many(:award_emoji).dependent(:destroy) } @@ -37,6 +36,34 @@ describe User, models: true do it { is_expected.to have_many(:pipelines).dependent(:nullify) } it { is_expected.to have_many(:chat_names).dependent(:destroy) } it { is_expected.to have_many(:uploads).dependent(:destroy) } + it { is_expected.to have_many(:reported_abuse_reports).dependent(:destroy).class_name('AbuseReport') } + + describe "#abuse_report" do + let(:current_user) { create(:user) } + let(:other_user) { create(:user) } + + it { is_expected.to have_one(:abuse_report) } + + it "refers to the abuse report whose user_id is the current user" do + abuse_report = create(:abuse_report, reporter: other_user, user: current_user) + + expect(current_user.abuse_report).to eq(abuse_report) + end + + it "does not refer to the abuse report whose reporter_id is the current user" do + create(:abuse_report, reporter: current_user, user: other_user) + + expect(current_user.abuse_report).to be_nil + end + + it "does not update the user_id of an abuse report when the user is updated" do + abuse_report = create(:abuse_report, reporter: current_user, user: other_user) + + current_user.block + + expect(abuse_report.reload.user).to eq(other_user) + end + end describe '#group_members' do it 'does not include group memberships for which user is a requester' do @@ -1407,6 +1434,17 @@ describe User, models: true do it { expect(user.nested_groups).to eq([nested_group]) } end + describe '#all_expanded_groups' do + let!(:user) { create(:user) } + let!(:group) { create(:group) } + let!(:nested_group_1) { create(:group, parent: group) } + let!(:nested_group_2) { create(:group, parent: group) } + + before { nested_group_1.add_owner(user) } + + it { expect(user.all_expanded_groups).to match_array [group, nested_group_1] } + end + describe '#nested_groups_projects' do let!(:user) { create(:user) } let!(:group) { create(:group) } @@ -1521,4 +1559,76 @@ describe User, models: true do end end end + + describe '#update_two_factor_requirement' do + let(:user) { create :user } + + context 'with 2FA requirement on groups' do + let(:group1) { create :group, require_two_factor_authentication: true, two_factor_grace_period: 23 } + let(:group2) { create :group, require_two_factor_authentication: true, two_factor_grace_period: 32 } + + before do + group1.add_user(user, GroupMember::OWNER) + group2.add_user(user, GroupMember::OWNER) + + user.update_two_factor_requirement + end + + it 'requires 2FA' do + expect(user.require_two_factor_authentication_from_group).to be true + end + + it 'uses the shortest grace period' do + expect(user.two_factor_grace_period).to be 23 + end + end + + context 'with 2FA requirement on nested parent group' do + let!(:group1) { create :group, require_two_factor_authentication: true } + let!(:group1a) { create :group, require_two_factor_authentication: false, parent: group1 } + + before do + group1a.add_user(user, GroupMember::OWNER) + + user.update_two_factor_requirement + end + + it 'requires 2FA' do + expect(user.require_two_factor_authentication_from_group).to be true + end + end + + context 'with 2FA requirement on nested child group' do + let!(:group1) { create :group, require_two_factor_authentication: false } + let!(:group1a) { create :group, require_two_factor_authentication: true, parent: group1 } + + before do + group1.add_user(user, GroupMember::OWNER) + + user.update_two_factor_requirement + end + + it 'requires 2FA' do + expect(user.require_two_factor_authentication_from_group).to be true + end + end + + context 'without 2FA requirement on groups' do + let(:group) { create :group } + + before do + group.add_user(user, GroupMember::OWNER) + + user.update_two_factor_requirement + end + + it 'does not require 2FA' do + expect(user.require_two_factor_authentication_from_group).to be false + end + + it 'falls back to the default grace period' do + expect(user.two_factor_grace_period).to be 48 + end + end + end end diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index 2b27ce6390a..551aae7d701 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -840,7 +840,7 @@ describe API::Issues, api: true do end context 'resolving discussions' do - let(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request)]).first } + let(:discussion) { create(:diff_note_on_merge_request).to_discussion } let(:merge_request) { discussion.noteable } let(:project) { merge_request.source_project } diff --git a/spec/requests/api/jobs_spec.rb b/spec/requests/api/jobs_spec.rb index 9450701064b..d8a56c02a63 100644 --- a/spec/requests/api/jobs_spec.rb +++ b/spec/requests/api/jobs_spec.rb @@ -320,7 +320,7 @@ describe API::Jobs, api: true do context 'authorized user' do it 'returns specific job trace' do expect(response).to have_http_status(200) - expect(response.body).to eq(build.trace) + expect(response.body).to eq(build.trace.raw) end end @@ -408,7 +408,7 @@ describe API::Jobs, api: true do it 'erases job content' do expect(response).to have_http_status(201) - expect(build.trace).to be_empty + expect(build).not_to have_trace expect(build.artifacts_file.exists?).to be_falsy expect(build.artifacts_metadata.exists?).to be_falsy end diff --git a/spec/requests/api/runner_spec.rb b/spec/requests/api/runner_spec.rb index 1cfac7353d4..409a59d6c23 100644 --- a/spec/requests/api/runner_spec.rb +++ b/spec/requests/api/runner_spec.rb @@ -592,7 +592,7 @@ describe API::Runner do update_job(trace: 'BUILD TRACE UPDATED') expect(response).to have_http_status(200) - expect(job.reload.trace).to eq 'BUILD TRACE UPDATED' + expect(job.reload.trace.raw).to eq 'BUILD TRACE UPDATED' end end @@ -600,7 +600,7 @@ describe API::Runner do it 'does not override trace information' do update_job - expect(job.reload.trace).to eq 'BUILD TRACE' + expect(job.reload.trace.raw).to eq 'BUILD TRACE' end end @@ -631,7 +631,7 @@ describe API::Runner do context 'when request is valid' do it 'gets correct response' do expect(response.status).to eq 202 - expect(job.reload.trace).to eq 'BUILD TRACE appended' + expect(job.reload.trace.raw).to eq 'BUILD TRACE appended' expect(response.header).to have_key 'Range' expect(response.header).to have_key 'Job-Status' end @@ -642,7 +642,7 @@ describe API::Runner do it "changes the job's trace" do patch_the_trace - expect(job.reload.trace).to eq 'BUILD TRACE appended appended' + expect(job.reload.trace.raw).to eq 'BUILD TRACE appended appended' end context 'when Runner makes a force-patch' do @@ -651,7 +651,7 @@ describe API::Runner do it "doesn't change the build.trace" do force_patch_the_trace - expect(job.reload.trace).to eq 'BUILD TRACE appended' + expect(job.reload.trace.raw).to eq 'BUILD TRACE appended' end end end @@ -664,7 +664,7 @@ describe API::Runner do it 'changes the job.trace' do patch_the_trace - expect(job.reload.trace).to eq 'BUILD TRACE appended appended' + expect(job.reload.trace.raw).to eq 'BUILD TRACE appended appended' end context 'when Runner makes a force-patch' do @@ -673,7 +673,7 @@ describe API::Runner do it "doesn't change the job.trace" do force_patch_the_trace - expect(job.reload.trace).to eq 'BUILD TRACE appended' + expect(job.reload.trace.raw).to eq 'BUILD TRACE appended' end end end @@ -698,7 +698,7 @@ describe API::Runner do it 'gets correct response' do expect(response.status).to eq 202 - expect(job.reload.trace).to eq 'BUILD TRACE appended' + expect(job.reload.trace.raw).to eq 'BUILD TRACE appended' expect(response.header).to have_key 'Range' expect(response.header).to have_key 'Job-Status' end @@ -738,9 +738,11 @@ describe API::Runner do def patch_the_trace(content = ' appended', request_headers = nil) unless request_headers - offset = job.trace_length - limit = offset + content.length - 1 - request_headers = headers.merge({ 'Content-Range' => "#{offset}-#{limit}" }) + job.trace.read do |stream| + offset = stream.size + limit = offset + content.length - 1 + request_headers = headers.merge({ 'Content-Range' => "#{offset}-#{limit}" }) + end end Timecop.travel(job.updated_at + update_interval) do diff --git a/spec/requests/api/v3/builds_spec.rb b/spec/requests/api/v3/builds_spec.rb index a50c22a6dd1..e97d2b0cee0 100644 --- a/spec/requests/api/v3/builds_spec.rb +++ b/spec/requests/api/v3/builds_spec.rb @@ -330,7 +330,7 @@ describe API::V3::Builds, api: true do context 'authorized user' do it 'returns specific job trace' do expect(response).to have_http_status(200) - expect(response.body).to eq(build.trace) + expect(response.body).to eq(build.trace.raw) end end @@ -418,7 +418,7 @@ describe API::V3::Builds, api: true do it 'erases job content' do expect(response.status).to eq 201 - expect(build.trace).to be_empty + expect(build).not_to have_trace expect(build.artifacts_file.exists?).to be_falsy expect(build.artifacts_metadata.exists?).to be_falsy end diff --git a/spec/requests/api/v3/issues_spec.rb b/spec/requests/api/v3/issues_spec.rb index b1b398a897e..91d9057075f 100644 --- a/spec/requests/api/v3/issues_spec.rb +++ b/spec/requests/api/v3/issues_spec.rb @@ -824,7 +824,7 @@ describe API::V3::Issues, api: true do end context 'resolving issues in a merge request' do - let(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request)]).first } + let(:discussion) { create(:diff_note_on_merge_request).to_discussion } let(:merge_request) { discussion.noteable } let(:project) { merge_request.source_project } before do diff --git a/spec/requests/ci/api/builds_spec.rb b/spec/requests/ci/api/builds_spec.rb index c879f37f50d..ef30d8638dd 100644 --- a/spec/requests/ci/api/builds_spec.rb +++ b/spec/requests/ci/api/builds_spec.rb @@ -285,7 +285,7 @@ describe Ci::API::Builds do end it 'does not override trace information when no trace is given' do - expect(build.reload.trace).to eq 'BUILD TRACE' + expect(build.reload.trace.raw).to eq 'BUILD TRACE' end context 'job has been erased' do @@ -309,9 +309,11 @@ describe Ci::API::Builds do def patch_the_trace(content = ' appended', request_headers = nil) unless request_headers - offset = build.trace_length - limit = offset + content.length - 1 - request_headers = headers.merge({ 'Content-Range' => "#{offset}-#{limit}" }) + build.trace.read do |stream| + offset = stream.size + limit = offset + content.length - 1 + request_headers = headers.merge({ 'Content-Range' => "#{offset}-#{limit}" }) + end end Timecop.travel(build.updated_at + update_interval) do @@ -335,7 +337,7 @@ describe Ci::API::Builds do context 'when request is valid' do it 'gets correct response' do expect(response.status).to eq 202 - expect(build.reload.trace).to eq 'BUILD TRACE appended' + expect(build.reload.trace.raw).to eq 'BUILD TRACE appended' expect(response.header).to have_key 'Range' expect(response.header).to have_key 'Build-Status' end @@ -346,7 +348,7 @@ describe Ci::API::Builds do it 'changes the build trace' do patch_the_trace - expect(build.reload.trace).to eq 'BUILD TRACE appended appended' + expect(build.reload.trace.raw).to eq 'BUILD TRACE appended appended' end context 'when Runner makes a force-patch' do @@ -355,7 +357,7 @@ describe Ci::API::Builds do it "doesn't change the build.trace" do force_patch_the_trace - expect(build.reload.trace).to eq 'BUILD TRACE appended' + expect(build.reload.trace.raw).to eq 'BUILD TRACE appended' end end end @@ -368,7 +370,7 @@ describe Ci::API::Builds do it 'changes the build.trace' do patch_the_trace - expect(build.reload.trace).to eq 'BUILD TRACE appended appended' + expect(build.reload.trace.raw).to eq 'BUILD TRACE appended appended' end context 'when Runner makes a force-patch' do @@ -377,7 +379,7 @@ describe Ci::API::Builds do it "doesn't change the build.trace" do force_patch_the_trace - expect(build.reload.trace).to eq 'BUILD TRACE appended' + expect(build.reload.trace.raw).to eq 'BUILD TRACE appended' end end end @@ -403,7 +405,7 @@ describe Ci::API::Builds do it 'gets correct response' do expect(response.status).to eq 202 - expect(build.reload.trace).to eq 'BUILD TRACE appended' + expect(build.reload.trace.raw).to eq 'BUILD TRACE appended' expect(response.header).to have_key 'Range' expect(response.header).to have_key 'Build-Status' end diff --git a/spec/services/auth/container_registry_authentication_service_spec.rb b/spec/services/auth/container_registry_authentication_service_spec.rb index b91234ddb1e..e273dfe1552 100644 --- a/spec/services/auth/container_registry_authentication_service_spec.rb +++ b/spec/services/auth/container_registry_authentication_service_spec.rb @@ -6,14 +6,15 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do let(:current_params) { {} } let(:rsa_key) { OpenSSL::PKey::RSA.generate(512) } let(:payload) { JWT.decode(subject[:token], rsa_key).first } + let(:authentication_abilities) do - [ - :read_container_image, - :create_container_image - ] + [:read_container_image, :create_container_image] end - subject { described_class.new(current_project, current_user, current_params).execute(authentication_abilities: authentication_abilities) } + subject do + described_class.new(current_project, current_user, current_params) + .execute(authentication_abilities: authentication_abilities) + end before do allow(Gitlab.config.registry).to receive_messages(enabled: true, issuer: 'rspec', key: nil) @@ -40,13 +41,11 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do end end - shared_examples 'a accessible' do + shared_examples 'an accessible' do let(:access) do - [{ - 'type' => 'repository', + [{ 'type' => 'repository', 'name' => project.path_with_namespace, - 'actions' => actions, - }] + 'actions' => actions }] end it_behaves_like 'a valid token' @@ -59,19 +58,19 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do end shared_examples 'a pullable' do - it_behaves_like 'a accessible' do + it_behaves_like 'an accessible' do let(:actions) { ['pull'] } end end shared_examples 'a pushable' do - it_behaves_like 'a accessible' do + it_behaves_like 'an accessible' do let(:actions) { ['push'] } end end shared_examples 'a pullable and pushable' do - it_behaves_like 'a accessible' do + it_behaves_like 'an accessible' do let(:actions) { %w(pull push) } end end @@ -81,15 +80,30 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do it { is_expected.not_to include(:token) } end + shared_examples 'container repository factory' do + it 'creates a new container repository resource' do + expect { subject } + .to change { project.container_repositories.count }.by(1) + end + end + + shared_examples 'not a container repository factory' do + it 'does not create a new container repository resource' do + expect { subject }.not_to change { ContainerRepository.count } + end + end + describe '#full_access_token' do let(:project) { create(:empty_project) } let(:token) { described_class.full_access_token(project.path_with_namespace) } subject { { token: token } } - it_behaves_like 'a accessible' do + it_behaves_like 'an accessible' do let(:actions) { ['*'] } end + + it_behaves_like 'not a container repository factory' end context 'user authorization' do @@ -110,16 +124,20 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do end it_behaves_like 'a pushable' + it_behaves_like 'container repository factory' end context 'allow reporter to pull images' do before { project.team << [current_user, :reporter] } - let(:current_params) do - { scope: "repository:#{project.path_with_namespace}:pull" } - end + context 'when pulling from root level repository' do + let(:current_params) do + { scope: "repository:#{project.path_with_namespace}:pull" } + end - it_behaves_like 'a pullable' + it_behaves_like 'a pullable' + it_behaves_like 'not a container repository factory' + end end context 'return a least of privileges' do @@ -130,6 +148,7 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do end it_behaves_like 'a pullable' + it_behaves_like 'not a container repository factory' end context 'disallow guest to pull or push images' do @@ -140,6 +159,7 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do end it_behaves_like 'an inaccessible' + it_behaves_like 'not a container repository factory' end end @@ -152,6 +172,7 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do end it_behaves_like 'a pullable' + it_behaves_like 'not a container repository factory' end context 'disallow anyone to push images' do @@ -160,6 +181,16 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do end it_behaves_like 'an inaccessible' + it_behaves_like 'not a container repository factory' + end + + context 'when repository name is invalid' do + let(:current_params) do + { scope: 'repository:invalid:push' } + end + + it_behaves_like 'an inaccessible' + it_behaves_like 'not a container repository factory' end end @@ -173,6 +204,7 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do end it_behaves_like 'a pullable' + it_behaves_like 'not a container repository factory' end context 'disallow anyone to push images' do @@ -181,6 +213,7 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do end it_behaves_like 'an inaccessible' + it_behaves_like 'not a container repository factory' end end @@ -191,6 +224,7 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do end it_behaves_like 'an inaccessible' + it_behaves_like 'not a container repository factory' end end end @@ -198,11 +232,9 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do context 'build authorized as user' do let(:current_project) { create(:empty_project) } let(:current_user) { create(:user) } + let(:authentication_abilities) do - [ - :build_read_container_image, - :build_create_container_image - ] + [:build_read_container_image, :build_create_container_image] end before do @@ -219,6 +251,10 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do it_behaves_like 'a pullable and pushable' do let(:project) { current_project } end + + it_behaves_like 'container repository factory' do + let(:project) { current_project } + end end context 'for other projects' do @@ -231,11 +267,13 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do let(:project) { create(:empty_project, :public) } it_behaves_like 'a pullable' + it_behaves_like 'not a container repository factory' end shared_examples 'pullable for being team member' do context 'when you are not member' do it_behaves_like 'an inaccessible' + it_behaves_like 'not a container repository factory' end context 'when you are member' do @@ -244,12 +282,14 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do end it_behaves_like 'a pullable' + it_behaves_like 'not a container repository factory' end context 'when you are owner' do let(:project) { create(:empty_project, namespace: current_user.namespace) } it_behaves_like 'a pullable' + it_behaves_like 'not a container repository factory' end end @@ -263,6 +303,7 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do context 'when you are not member' do it_behaves_like 'an inaccessible' + it_behaves_like 'not a container repository factory' end context 'when you are member' do @@ -271,12 +312,14 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do end it_behaves_like 'a pullable' + it_behaves_like 'not a container repository factory' end context 'when you are owner' do let(:project) { create(:empty_project, namespace: current_user.namespace) } it_behaves_like 'a pullable' + it_behaves_like 'not a container repository factory' end end end @@ -296,12 +339,14 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do end it_behaves_like 'an inaccessible' + it_behaves_like 'not a container repository factory' end context 'when you are owner' do let(:project) { create(:empty_project, :public, namespace: current_user.namespace) } it_behaves_like 'an inaccessible' + it_behaves_like 'not a container repository factory' end end end @@ -318,6 +363,7 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do end it_behaves_like 'an inaccessible' + it_behaves_like 'not a container repository factory' end end end @@ -325,6 +371,7 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do context 'unauthorized' do context 'disallow to use scope-less authentication' do it_behaves_like 'a forbidden' + it_behaves_like 'not a container repository factory' end context 'for invalid scope' do @@ -333,6 +380,7 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do end it_behaves_like 'a forbidden' + it_behaves_like 'not a container repository factory' end context 'for private project' do @@ -354,6 +402,7 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do end it_behaves_like 'a pullable' + it_behaves_like 'not a container repository factory' end context 'when pushing' do @@ -362,6 +411,7 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do end it_behaves_like 'a forbidden' + it_behaves_like 'not a container repository factory' end end end diff --git a/spec/services/discussions/resolve_service_spec.rb b/spec/services/discussions/resolve_service_spec.rb index 12c3cdf28c6..ab8df7b74cd 100644 --- a/spec/services/discussions/resolve_service_spec.rb +++ b/spec/services/discussions/resolve_service_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' describe Discussions::ResolveService do describe '#execute' do - let(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request)]).first } + let(:discussion) { create(:diff_note_on_merge_request).to_discussion } let(:project) { merge_request.project } let(:merge_request) { discussion.noteable } let(:user) { create(:user) } @@ -41,7 +41,7 @@ describe Discussions::ResolveService do end it 'can resolve multiple discussions at once' do - other_discussion = Discussion.for_diff_notes([create(:diff_note_on_merge_request, noteable: discussion.noteable, project: discussion.noteable.source_project)]).first + other_discussion = create(:diff_note_on_merge_request, noteable: discussion.noteable, project: discussion.noteable.source_project).to_discussion service.execute([discussion, other_discussion]) diff --git a/spec/services/issues/build_service_spec.rb b/spec/services/issues/build_service_spec.rb index 17990f41b3b..55d635235b0 100644 --- a/spec/services/issues/build_service_spec.rb +++ b/spec/services/issues/build_service_spec.rb @@ -11,7 +11,7 @@ describe Issues::BuildService, services: true do context 'for a single discussion' do describe '#execute' do let(:merge_request) { create(:merge_request, title: "Hello world", source_project: project) } - let(:discussion) { Discussion.new([create(:diff_note_on_merge_request, project: project, noteable: merge_request, note: "Almost done")]) } + let(:discussion) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, note: "Almost done").to_discussion } let(:service) { described_class.new(project, user, merge_request_to_resolve_discussions_of: merge_request.iid, discussion_to_resolve: discussion.id) } it 'references the noteable title in the issue title' do @@ -47,7 +47,7 @@ describe Issues::BuildService, services: true do let(:service) { described_class.new(project, user, merge_request_to_resolve_discussions_of: merge_request.iid) } it 'mentions the author of the note' do - discussion = Discussion.new([create(:diff_note_on_merge_request, author: create(:user, username: 'author'))]) + discussion = create(:diff_note_on_merge_request, author: create(:user, username: 'author')).to_discussion expect(service.item_for_discussion(discussion)).to include('@author') end @@ -60,7 +60,7 @@ describe Issues::BuildService, services: true do note_result = " > This is a string\n"\ " > > with a blockquote\n"\ " > > > That has a quote\n" - discussion = Discussion.new([create(:diff_note_on_merge_request, note: note_text)]) + discussion = create(:diff_note_on_merge_request, note: note_text).to_discussion expect(service.item_for_discussion(discussion)).to include(note_result) end end @@ -91,25 +91,23 @@ describe Issues::BuildService, services: true do end describe 'with multiple discussions' do - before do - create(:diff_note_on_merge_request, noteable: merge_request, project: merge_request.target_project, line_number: 15) - end + let!(:diff_note) { create(:diff_note_on_merge_request, noteable: merge_request, project: merge_request.target_project, line_number: 15) } it 'mentions all the authors in the description' do - authors = merge_request.diff_discussions.map(&:author) + authors = merge_request.resolvable_discussions.map(&:author) expect(issue.description).to include(*authors.map(&:to_reference)) end it 'has a link for each unresolved discussion in the description' do - notes = merge_request.diff_discussions.map(&:first_note) + notes = merge_request.resolvable_discussions.map(&:first_note) links = notes.map { |note| Gitlab::UrlBuilder.build(note) } expect(issue.description).to include(*links) end it 'mentions additional notes' do - create_list(:diff_note_on_merge_request, 2, noteable: merge_request, project: merge_request.target_project, line_number: 15) + create_list(:diff_note_on_merge_request, 2, noteable: merge_request, project: merge_request.target_project, in_reply_to: diff_note) expect(issue.description).to include('(+2 comments)') end diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb index 776cbc4296b..80bfb731550 100644 --- a/spec/services/issues/create_service_spec.rb +++ b/spec/services/issues/create_service_spec.rb @@ -141,7 +141,7 @@ describe Issues::CreateService, services: true do it_behaves_like 'new issuable record that supports slash commands' context 'resolving discussions' do - let(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request)]).first } + let(:discussion) { create(:diff_note_on_merge_request).to_discussion } let(:merge_request) { discussion.noteable } let(:project) { merge_request.source_project } diff --git a/spec/services/issues/resolve_discussions_spec.rb b/spec/services/issues/resolve_discussions_spec.rb index 3a72f92383c..4a4929daefc 100644 --- a/spec/services/issues/resolve_discussions_spec.rb +++ b/spec/services/issues/resolve_discussions_spec.rb @@ -18,7 +18,7 @@ describe DummyService, services: true do end describe "for resolving discussions" do - let(:discussion) { Discussion.new([create(:diff_note_on_merge_request, project: project, note: "Almost done")]) } + let(:discussion) { create(:diff_note_on_merge_request, project: project, note: "Almost done").to_discussion } let(:merge_request) { discussion.noteable } let(:other_merge_request) { create(:merge_request, source_project: project, source_branch: "other") } diff --git a/spec/services/notes/build_service_spec.rb b/spec/services/notes/build_service_spec.rb new file mode 100644 index 00000000000..f9dd5541b10 --- /dev/null +++ b/spec/services/notes/build_service_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +describe Notes::BuildService, services: true do + let(:note) { create(:discussion_note_on_issue) } + let(:project) { note.project } + let(:author) { note.author } + + describe '#execute' do + context 'when in_reply_to_discussion_id is specified' do + context 'when a note with that original discussion ID exists' do + it 'sets the note up to be in reply to that note' do + new_note = described_class.new(project, author, note: 'Test', in_reply_to_discussion_id: note.discussion_id).execute + expect(new_note).to be_valid + expect(new_note.in_reply_to?(note)).to be_truthy + end + end + + context 'when a note with that discussion ID exists' do + it 'sets the note up to be in reply to that note' do + new_note = described_class.new(project, author, note: 'Test', in_reply_to_discussion_id: note.discussion_id).execute + expect(new_note).to be_valid + expect(new_note.in_reply_to?(note)).to be_truthy + end + end + + context 'when no note with that discussion ID exists' do + it 'sets an error' do + new_note = described_class.new(project, author, note: 'Test', in_reply_to_discussion_id: 'foo').execute + expect(new_note.errors[:base]).to include('Discussion to reply to cannot be found') + end + end + end + + it 'builds a note without saving it' do + new_note = described_class.new(project, author, noteable_type: note.noteable_type, noteable_id: note.noteable_id, note: 'Test').execute + expect(new_note).to be_valid + expect(new_note).not_to be_persisted + end + end +end diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index e3146a56495..617c8eaf3d5 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -439,7 +439,7 @@ describe NotificationService, services: true do notification.new_note(note) - expect(SentNotification.last.position).to eq(note.position) + expect(SentNotification.last.in_reply_to_discussion_id).to eq(note.discussion_id) end end end diff --git a/spec/services/projects/destroy_service_spec.rb b/spec/services/projects/destroy_service_spec.rb index b1e10f4562e..4b8589b2736 100644 --- a/spec/services/projects/destroy_service_spec.rb +++ b/spec/services/projects/destroy_service_spec.rb @@ -7,6 +7,11 @@ describe Projects::DestroyService, services: true do let!(:remove_path) { path.sub(/\.git\Z/, "+#{project.id}+deleted.git") } let!(:async) { false } # execute or async_execute + before do + stub_container_registry_config(enabled: true) + stub_container_registry_tags(repository: :any, tags: []) + end + shared_examples 'deleting the project' do it 'deletes the project' do expect(Project.unscoped.all).not_to include(project) @@ -89,30 +94,64 @@ describe Projects::DestroyService, services: true do it_behaves_like 'deleting the project with pipeline and build' end - context 'container registry' do - before do - stub_container_registry_config(enabled: true) - stub_container_registry_tags('tag') - end + describe 'container registry' do + context 'when there are regular container repositories' do + let(:container_repository) { create(:container_repository) } + + before do + stub_container_registry_tags(repository: project.full_path + '/image', + tags: ['tag']) + project.container_repositories << container_repository + end + + context 'when image repository deletion succeeds' do + it 'removes tags' do + expect_any_instance_of(ContainerRepository) + .to receive(:delete_tags!).and_return(true) + + destroy_project(project, user) + end + end - context 'tags deletion succeeds' do - it do - expect_any_instance_of(ContainerRegistry::Tag).to receive(:delete).and_return(true) + context 'when image repository deletion fails' do + it 'raises an exception' do + expect_any_instance_of(ContainerRepository) + .to receive(:delete_tags!).and_return(false) - destroy_project(project, user, {}) + expect{ destroy_project(project, user) } + .to raise_error(ActiveRecord::RecordNotDestroyed) + end end end - context 'tags deletion fails' do - before { expect_any_instance_of(ContainerRegistry::Tag).to receive(:delete).and_return(false) } + context 'when there are tags for legacy root repository' do + before do + stub_container_registry_tags(repository: project.full_path, + tags: ['tag']) + end + + context 'when image repository tags deletion succeeds' do + it 'removes tags' do + expect_any_instance_of(ContainerRepository) + .to receive(:delete_tags!).and_return(true) - subject { destroy_project(project, user, {}) } + destroy_project(project, user) + end + end + + context 'when image repository tags deletion fails' do + it 'raises an exception' do + expect_any_instance_of(ContainerRepository) + .to receive(:delete_tags!).and_return(false) - it { expect{subject}.to raise_error(Projects::DestroyService::DestroyError) } + expect { destroy_project(project, user) } + .to raise_error(Projects::DestroyService::DestroyError) + end + end end end - def destroy_project(project, user, params) + def destroy_project(project, user, params = {}) if async Projects::DestroyService.new(project, user, params).async_execute else diff --git a/spec/services/projects/transfer_service_spec.rb b/spec/services/projects/transfer_service_spec.rb index f8187fefc14..29ccce59c53 100644 --- a/spec/services/projects/transfer_service_spec.rb +++ b/spec/services/projects/transfer_service_spec.rb @@ -29,9 +29,12 @@ describe Projects::TransferService, services: true do end context 'disallow transfering of project with tags' do + let(:container_repository) { create(:container_repository) } + before do stub_container_registry_config(enabled: true) - stub_container_registry_tags('tag') + stub_container_registry_tags(repository: :any, tags: ['tag']) + project.container_repositories << container_repository end subject { transfer_project(project, user, group) } diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 5ec1ed8237b..42d63a9f9ba 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -796,7 +796,7 @@ describe SystemNoteService, services: true do end describe '.discussion_continued_in_issue' do - let(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request)]).first } + let(:discussion) { create(:diff_note_on_merge_request).to_discussion } let(:merge_request) { discussion.noteable } let(:project) { merge_request.source_project } let(:issue) { create(:issue, project: project) } diff --git a/spec/services/users/destroy_spec.rb b/spec/services/users/destroy_service_spec.rb index 66c61b7f8ff..43c18992d1a 100644 --- a/spec/services/users/destroy_spec.rb +++ b/spec/services/users/destroy_service_spec.rb @@ -46,43 +46,47 @@ describe Users::DestroyService, services: true do project.add_developer(user) end - context "for an issue the user has created" do - let!(:issue) { create(:issue, project: project, author: user) } + context "for an issue the user was assigned to" do + let!(:issue) { create(:issue, project: project, assignee: user) } before do service.execute(user) end - it 'does not delete the issue' do + it 'does not delete issues the user is assigned to' do expect(Issue.find_by_id(issue.id)).to be_present end - it 'migrates the issue so that the "Ghost User" is the issue owner' do + it 'migrates the issue so that it is "Unassigned"' do migrated_issue = Issue.find_by_id(issue.id) - expect(migrated_issue.author).to eq(User.ghost) + expect(migrated_issue.assignee).to be_nil end + end + end - it 'blocks the user before migrating issues to the "Ghost User' do - expect(user).to be_blocked - end + context "a deleted user's merge_requests" do + let(:project) { create(:project) } + + before do + project.add_developer(user) end - context "for an issue the user was assigned to" do - let!(:issue) { create(:issue, project: project, assignee: user) } + context "for an merge request the user was assigned to" do + let!(:merge_request) { create(:merge_request, source_project: project, assignee: user) } before do service.execute(user) end - it 'does not delete issues the user is assigned to' do - expect(Issue.find_by_id(issue.id)).to be_present + it 'does not delete merge requests the user is assigned to' do + expect(MergeRequest.find_by_id(merge_request.id)).to be_present end - it 'migrates the issue so that it is "Unassigned"' do - migrated_issue = Issue.find_by_id(issue.id) + it 'migrates the merge request so that it is "Unassigned"' do + migrated_merge_request = MergeRequest.find_by_id(merge_request.id) - expect(migrated_issue.assignee).to be_nil + expect(migrated_merge_request.assignee).to be_nil end end end @@ -141,5 +145,13 @@ describe Users::DestroyService, services: true do expect(User.exists?(user.id)).to be(false) end end + + context "migrating associated records" do + it 'delegates to the `MigrateToGhostUser` service to move associated records to the ghost user' do + expect_any_instance_of(Users::MigrateToGhostUserService).to receive(:execute).once + + service.execute(user) + end + end end end diff --git a/spec/services/users/migrate_to_ghost_user_service_spec.rb b/spec/services/users/migrate_to_ghost_user_service_spec.rb new file mode 100644 index 00000000000..8c5b7e41c15 --- /dev/null +++ b/spec/services/users/migrate_to_ghost_user_service_spec.rb @@ -0,0 +1,64 @@ +require 'spec_helper' + +describe Users::MigrateToGhostUserService, services: true do + let!(:user) { create(:user) } + let!(:project) { create(:project) } + let(:service) { described_class.new(user) } + + context "migrating a user's associated records to the ghost user" do + context 'issues' do + include_examples "migrating a deleted user's associated records to the ghost user", Issue do + let(:created_record) { create(:issue, project: project, author: user) } + let(:assigned_record) { create(:issue, project: project, assignee: user) } + end + end + + context 'merge requests' do + include_examples "migrating a deleted user's associated records to the ghost user", MergeRequest do + let(:created_record) { create(:merge_request, source_project: project, author: user, target_branch: "first") } + let(:assigned_record) { create(:merge_request, source_project: project, assignee: user, target_branch: 'second') } + end + end + + context 'notes' do + include_examples "migrating a deleted user's associated records to the ghost user", Note do + let(:created_record) { create(:note, project: project, author: user) } + end + end + + context 'abuse reports' do + include_examples "migrating a deleted user's associated records to the ghost user", AbuseReport do + let(:created_record) { create(:abuse_report, reporter: user, user: create(:user)) } + end + end + + context 'award emoji' do + include_examples "migrating a deleted user's associated records to the ghost user", AwardEmoji do + let(:created_record) { create(:award_emoji, user: user) } + let(:author_alias) { :user } + + context "when the awardable already has an award emoji of the same name assigned to the ghost user" do + let(:awardable) { create(:issue) } + let!(:existing_award_emoji) { create(:award_emoji, user: User.ghost, name: "thumbsup", awardable: awardable) } + let!(:award_emoji) { create(:award_emoji, user: user, name: "thumbsup", awardable: awardable) } + + it "migrates the award emoji regardless" do + service.execute + + migrated_record = AwardEmoji.find_by_id(award_emoji.id) + + expect(migrated_record.user).to eq(User.ghost) + end + + it "does not leave the migrated award emoji in an invalid state" do + service.execute + + migrated_record = AwardEmoji.find_by_id(award_emoji.id) + + expect(migrated_record).to be_valid + end + end + end + end + end +end diff --git a/spec/support/filtered_search_helpers.rb b/spec/support/filtered_search_helpers.rb index 6b009b132b6..36be0bb6bf8 100644 --- a/spec/support/filtered_search_helpers.rb +++ b/spec/support/filtered_search_helpers.rb @@ -30,7 +30,7 @@ module FilteredSearchHelpers end def clear_search_field - find('.filtered-search-input-container .clear-search').click + find('.filtered-search-box .clear-search').click end def reset_filters @@ -51,7 +51,7 @@ module FilteredSearchHelpers # Iterates through each visual token inside # .tokens-container to make sure the correct names and values are rendered def expect_tokens(tokens) - page.find '.filtered-search-input-container .tokens-container' do + page.find '.filtered-search-box .tokens-container' do page.all(:css, '.tokens-container li').each_with_index do |el, index| token_name = tokens[index][:name] token_value = tokens[index][:value] @@ -71,4 +71,18 @@ module FilteredSearchHelpers def get_filtered_search_placeholder find('.filtered-search')['placeholder'] end + + def remove_recent_searches + execute_script('window.localStorage.removeItem(\'issue-recent-searches\');') + end + + def set_recent_searches(input) + execute_script("window.localStorage.setItem('issue-recent-searches', '#{input}');") + end + + def wait_for_filtered_search(text) + Timeout.timeout(Capybara.default_max_wait_time) do + loop until find('.filtered-search').value.strip == text + end + end end diff --git a/spec/support/services/migrate_to_ghost_user_service_shared_examples.rb b/spec/support/services/migrate_to_ghost_user_service_shared_examples.rb new file mode 100644 index 00000000000..0eac587e973 --- /dev/null +++ b/spec/support/services/migrate_to_ghost_user_service_shared_examples.rb @@ -0,0 +1,39 @@ +require "spec_helper" + +shared_examples "migrating a deleted user's associated records to the ghost user" do |record_class| + record_class_name = record_class.to_s.titleize.downcase + + let(:project) { create(:project) } + + before do + project.add_developer(user) + end + + context "for a #{record_class_name} the user has created" do + let!(:record) { created_record } + + it "does not delete the #{record_class_name}" do + service.execute + + expect(record_class.find_by_id(record.id)).to be_present + end + + it "migrates the #{record_class_name} so that the 'Ghost User' is the #{record_class_name} owner" do + service.execute + + migrated_record = record_class.find_by_id(record.id) + + if migrated_record.respond_to?(:author) + expect(migrated_record.author).to eq(User.ghost) + else + expect(migrated_record.send(author_alias)).to eq(User.ghost) + end + end + + it "blocks the user before migrating #{record_class_name}s to the 'Ghost User'" do + service.execute + + expect(user).to be_blocked + end + end +end diff --git a/spec/support/stub_gitlab_calls.rb b/spec/support/stub_gitlab_calls.rb index a01ef576234..ded2d593059 100644 --- a/spec/support/stub_gitlab_calls.rb +++ b/spec/support/stub_gitlab_calls.rb @@ -27,23 +27,40 @@ module StubGitlabCalls def stub_container_registry_config(registry_settings) allow(Gitlab.config.registry).to receive_messages(registry_settings) - allow(Auth::ContainerRegistryAuthenticationService).to receive(:full_access_token).and_return('token') + allow(Auth::ContainerRegistryAuthenticationService) + .to receive(:full_access_token).and_return('token') end - def stub_container_registry_tags(*tags) - allow_any_instance_of(ContainerRegistry::Client).to receive(:repository_tags).and_return( - { "tags" => tags } - ) - allow_any_instance_of(ContainerRegistry::Client).to receive(:repository_manifest).and_return( - JSON.parse(File.read(Rails.root + 'spec/fixtures/container_registry/tag_manifest.json')) - ) - allow_any_instance_of(ContainerRegistry::Client).to receive(:blob).and_return( - File.read(Rails.root + 'spec/fixtures/container_registry/config_blob.json') - ) + def stub_container_registry_tags(repository: :any, tags:) + repository = any_args if repository == :any + + allow_any_instance_of(ContainerRegistry::Client) + .to receive(:repository_tags).with(repository) + .and_return({ 'tags' => tags }) + + allow_any_instance_of(ContainerRegistry::Client) + .to receive(:repository_manifest).with(repository) + .and_return(stub_container_registry_tag_manifest) + + allow_any_instance_of(ContainerRegistry::Client) + .to receive(:blob).with(repository) + .and_return(stub_container_registry_blob) end private + def stub_container_registry_tag_manifest + fixture_path = 'spec/fixtures/container_registry/tag_manifest.json' + + JSON.parse(File.read(Rails.root + fixture_path)) + end + + def stub_container_registry_blob + fixture_path = 'spec/fixtures/container_registry/config_blob.json' + + File.read(Rails.root + fixture_path) + end + def gitlab_url Gitlab.config.gitlab.url end diff --git a/spec/workers/trigger_schedule_worker_spec.rb b/spec/workers/trigger_schedule_worker_spec.rb new file mode 100644 index 00000000000..151e1c2f7b9 --- /dev/null +++ b/spec/workers/trigger_schedule_worker_spec.rb @@ -0,0 +1,58 @@ +require 'spec_helper' + +describe TriggerScheduleWorker do + let(:worker) { described_class.new } + + before do + stub_ci_pipeline_to_return_yaml_file + end + + context 'when there is a scheduled trigger within next_run_at' do + before do + trigger_schedule = create(:ci_trigger_schedule, :nightly) + time_future = Time.now + 10.days + allow(Time).to receive(:now).and_return(time_future) + @next_time = Gitlab::Ci::CronParser.new(trigger_schedule.cron, trigger_schedule.cron_timezone).next_time_from(time_future) + end + + it 'creates a new trigger request' do + expect { worker.perform }.to change { Ci::TriggerRequest.count }.by(1) + end + + it 'creates a new pipeline' do + expect { worker.perform }.to change { Ci::Pipeline.count }.by(1) + expect(Ci::Pipeline.last).to be_pending + end + + it 'updates next_run_at' do + expect { worker.perform }.to change { Ci::TriggerSchedule.last.next_run_at }.to(@next_time) + end + end + + context 'when there are no scheduled triggers within next_run_at' do + before { create(:ci_trigger_schedule, :nightly) } + + it 'does not create a new pipeline' do + expect { worker.perform }.not_to change { Ci::Pipeline.count } + end + + it 'does not update next_run_at' do + expect { worker.perform }.not_to change { Ci::TriggerSchedule.last.next_run_at } + end + end + + context 'when next_run_at is nil' do + before do + trigger_schedule = create(:ci_trigger_schedule, :nightly) + trigger_schedule.update_attribute(:next_run_at, nil) + end + + it 'does not create a new pipeline' do + expect { worker.perform }.not_to change { Ci::Pipeline.count } + end + + it 'does not update next_run_at' do + expect { worker.perform }.not_to change { Ci::TriggerSchedule.last.next_run_at } + end + end +end diff --git a/yarn.lock b/yarn.lock index 8f0be2c50a1..e08c2f7f053 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,12 @@ # yarn lockfile v1 +"@gitlab-org/droplab@^0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@gitlab-org/droplab/-/droplab-0.1.0.tgz#934889d0f19417198e277415fdd336616b2e2c87" + dependencies: + custom-event-polyfill "^0.3.0" + abbrev@1, abbrev@1.0.x: version "1.0.9" resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.0.9.tgz#91b4792588a7738c25f35dd6f63752a2f8776135" @@ -1301,6 +1307,10 @@ crypto-browserify@^3.11.0: public-encrypt "^4.0.0" randombytes "^2.0.0" +custom-event-polyfill@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/custom-event-polyfill/-/custom-event-polyfill-0.3.0.tgz#99807839be62edb446b645832e0d80ead6fa1888" + custom-event@~1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/custom-event/-/custom-event-1.0.1.tgz#5d02a46850adf1b4a317946a3928fccb5bfd0425" @@ -2895,8 +2905,8 @@ karma-webpack@^2.0.2: webpack-dev-middleware "^1.0.11" karma@^1.4.1: - version "1.5.0" - resolved "https://registry.yarnpkg.com/karma/-/karma-1.5.0.tgz#9c4c14f0400bef2c04c8e8e6bff59371025cc009" + version "1.6.0" + resolved "https://registry.yarnpkg.com/karma/-/karma-1.6.0.tgz#0e871d4527d5eac56c41d181f03c5c0a7e6dbf3e" dependencies: bluebird "^3.3.0" body-parser "^1.16.1" @@ -2915,7 +2925,7 @@ karma@^1.4.1: lodash "^3.8.0" log4js "^0.6.31" mime "^1.3.4" - minimatch "^3.0.0" + minimatch "^3.0.2" optimist "^0.6.1" qjobs "^1.1.4" range-parser "^1.2.0" @@ -3212,8 +3222,8 @@ mute-stream@0.0.5: resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.5.tgz#8fbfabb0a98a253d3184331f9e8deb7372fac6c0" nan@^2.0.0, nan@^2.3.0: - version "2.5.1" - resolved "https://registry.yarnpkg.com/nan/-/nan-2.5.1.tgz#d5b01691253326a97a2bbee9e61c55d8d60351e2" + version "2.6.1" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.6.1.tgz#8c84f7b14c96b89f57fbc838012180ec8ca39a01" natural-compare@^1.4.0: version "1.4.0" |