diff options
99 files changed, 1172 insertions, 123 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b3593df8b13..ccc9e640970 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -139,7 +139,7 @@ stages: - export SCRIPT_NAME="${SCRIPT_NAME:-$CI_JOB_NAME}" - apk add --update openssl - wget $CI_PROJECT_URL/raw/$CI_COMMIT_SHA/scripts/$SCRIPT_NAME - - chmod 755 $SCRIPT_NAME + - chmod 755 $(basename $SCRIPT_NAME) .rake-exec: &rake-exec <<: *dedicated-no-docs-no-db-pull-cache-job @@ -929,3 +929,94 @@ no_ee_check: - scripts/no-ee-check only: - //@gitlab-org/gitlab-ce + +# GitLab Review apps +review: + image: registry.gitlab.com/gitlab-org/gitlab-build-images:gitlab-charts-build-base + stage: test + allow_failure: true + before_script: + - gem install gitlab --no-document + variables: + GIT_DEPTH: "1" + HOST_SUFFIX: "$CI_ENVIRONMENT_SLUG" + DOMAIN: "-$CI_ENVIRONMENT_SLUG.$REVIEW_APPS_DOMAIN" + GITLAB_HELM_CHART_REF: "master" + script: + - export GITLAB_SHELL_VERSION=$(<GITLAB_SHELL_VERSION) + - export GITALY_VERSION=$(<GITALY_SERVER_VERSION) + - export GITLAB_WORKHORSE_VERSION=$(<GITLAB_WORKHORSE_VERSION) + - source ./scripts/review_apps/review-apps.sh + - BUILD_TRIGGER_TOKEN=$REVIEW_APPS_BUILD_TRIGGER_TOKEN ./scripts/trigger-build cng + - check_kube_domain + - download_gitlab_chart + - ensure_namespace + - install_tiller + - create_secret + - install_external_dns + - deploy + environment: + name: review/$CI_COMMIT_REF_NAME + url: https://gitlab-$CI_ENVIRONMENT_SLUG.$REVIEW_APPS_DOMAIN + on_stop: stop_review + only: + refs: + - branches@gitlab-org/gitlab-ce + - branches@gitlab-org/gitlab-ee + kubernetes: active + except: + refs: + - master + - /(^docs[\/-].*|.*-docs$)/ + +stop_review: + <<: *single-script-job + image: registry.gitlab.com/gitlab-org/gitlab-build-images:gitlab-charts-build-base + stage: test + allow_failure: true + cache: {} + dependencies: [] + variables: + SCRIPT_NAME: "review_apps/review-apps.sh" + script: + - source $(basename "${SCRIPT_NAME}") + - delete + - cleanup + when: manual + environment: + name: review/$CI_COMMIT_REF_NAME + action: stop + only: + refs: + - branches@gitlab-org/gitlab-ce + - branches@gitlab-org/gitlab-ee + kubernetes: active + except: + - master + - /(^docs[\/-].*|.*-docs$)/ + +schedule:review_apps_cleanup: + <<: *dedicated-no-docs-pull-cache-job + image: registry.gitlab.com/gitlab-org/gitlab-build-images:gitlab-charts-build-base + stage: build + allow_failure: true + cache: {} + dependencies: [] + before_script: + - gem install gitlab --no-document + variables: + GIT_DEPTH: "1" + script: + - ruby -rrubygems scripts/review_apps/automated_cleanup.rb + environment: + name: review/auto-cleanup + action: stop + only: + refs: + - schedules@gitlab-org/gitlab-ce + - schedules@gitlab-org/gitlab-ee + kubernetes: active + except: + - master + - tags + - /(^docs[\/-].*|.*-docs$)/ diff --git a/.gitlab/issue_templates/Security developer workflow.md b/.gitlab/issue_templates/Security developer workflow.md index 64b54b171f7..69cf7fe1548 100644 --- a/.gitlab/issue_templates/Security developer workflow.md +++ b/.gitlab/issue_templates/Security developer workflow.md @@ -16,7 +16,6 @@ Set the title to: `[Security] Description of the original issue` - [ ] Add a link to the MR to the [links section](#links) - [ ] Add a link to an EE MR if required - [ ] Make sure the MR remains in-progress and gets approved after the review cycle, **but never merged**. -- [ ] Assign the MR to a RM once is reviewed and ready to be merged. Check the [RM list] to see who to ping. #### Backports @@ -26,7 +25,8 @@ Set the title to: `[Security] Description of the original issue` - [ ] Create the branch `security-X-Y` from `X-Y-stable` if it doesn't exist (and make sure it's up to date with stable) - [ ] Create each MR targetting the security branch `security-X-Y` - [ ] Add the ~security label and prefix with the version `WIP: [X.Y]` the title of the MR -- [ ] Make sure all MRs have a link in the [links section](#links) and are assigned to a Release Manager. +- [ ] Add the ~"Merge into Security" label to all of the MRs. +- [ ] Make sure all MRs have a link in the [links section](#links) [secpick documentation]: https://gitlab.com/gitlab-org/release/docs/blob/master/general/security/developer.md#secpick-script diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 33e061fe7a0..bcc9c2840a7 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -0.125.1 +0.126.0 @@ -417,8 +417,7 @@ end gem 'gitaly-proto', '~> 0.118.1', require: 'gitaly' gem 'grpc', '~> 1.15.0' -# Locked until https://github.com/google/protobuf/issues/4210 is closed -gem 'google-protobuf', '= 3.5.1' +gem 'google-protobuf', '~> 3.6' gem 'toml-rb', '~> 1.0.0', require: false diff --git a/Gemfile.lock b/Gemfile.lock index a39788bee9f..bf16bef4f32 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -303,7 +303,7 @@ GEM mime-types (~> 3.0) representable (~> 3.0) retriable (>= 2.0, < 4.0) - google-protobuf (3.5.1) + google-protobuf (3.6.1) googleapis-common-protos-types (1.0.2) google-protobuf (~> 3.0) googleauth (0.6.6) @@ -1005,7 +1005,7 @@ DEPENDENCIES gitlab_omniauth-ldap (~> 2.0.4) gon (~> 6.2) google-api-client (~> 0.23) - google-protobuf (= 3.5.1) + google-protobuf (~> 3.6) gpgme grape (~> 1.1) grape-entity (~> 0.7.1) diff --git a/Gemfile.rails5.lock b/Gemfile.rails5.lock index 1421edb1d39..81547303ed2 100644 --- a/Gemfile.rails5.lock +++ b/Gemfile.rails5.lock @@ -306,7 +306,7 @@ GEM mime-types (~> 3.0) representable (~> 3.0) retriable (>= 2.0, < 4.0) - google-protobuf (3.5.1) + google-protobuf (3.6.1) googleapis-common-protos-types (1.0.2) google-protobuf (~> 3.0) googleauth (0.6.6) @@ -1014,7 +1014,7 @@ DEPENDENCIES gitlab_omniauth-ldap (~> 2.0.4) gon (~> 6.2) google-api-client (~> 0.23) - google-protobuf (= 3.5.1) + google-protobuf (~> 3.6) gpgme grape (~> 1.1) grape-entity (~> 0.7.1) diff --git a/app/assets/javascripts/activities.js b/app/assets/javascripts/activities.js index de4566bb119..05de970e387 100644 --- a/app/assets/javascripts/activities.js +++ b/app/assets/javascripts/activities.js @@ -6,10 +6,12 @@ import Pager from './pager'; import { localTimeAgo } from './lib/utils/datetime_utility'; export default class Activities { - constructor() { - Pager.init(20, true, false, data => data, this.updateTooltips); + constructor(container = '') { + this.container = container; - $('.event-filter-link').on('click', (e) => { + Pager.init(20, true, false, data => data, this.updateTooltips, this.container); + + $('.event-filter-link').on('click', e => { e.preventDefault(); this.toggleFilter(e.currentTarget); this.reloadActivities(); @@ -22,7 +24,7 @@ export default class Activities { reloadActivities() { $('.content_list').html(''); - Pager.init(20, true, false, data => data, this.updateTooltips); + Pager.init(20, true, false, data => data, this.updateTooltips, this.container); } toggleFilter(sender) { diff --git a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js index c4f0c41d3a8..b70125c80ca 100644 --- a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js @@ -68,6 +68,11 @@ export const conditions = [ value: 'none', }, { + url: 'milestone_title=Any+Milestone', + tokenKey: 'milestone', + value: 'any', + }, + { url: 'milestone_title=%23upcoming', tokenKey: 'milestone', value: 'upcoming', diff --git a/app/assets/javascripts/jobs/components/job_container_item.vue b/app/assets/javascripts/jobs/components/job_container_item.vue index 81cc0823792..6486b25c8a7 100644 --- a/app/assets/javascripts/jobs/components/job_container_item.vue +++ b/app/assets/javascripts/jobs/components/job_container_item.vue @@ -1,5 +1,4 @@ <script> -import _ from 'underscore'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import Icon from '~/vue_shared/components/icon.vue'; import tooltip from '~/vue_shared/directives/tooltip'; @@ -9,11 +8,9 @@ export default { CiIcon, Icon, }, - directives: { tooltip, }, - props: { job: { type: Object, @@ -24,10 +21,9 @@ export default { required: true, }, }, - computed: { tooltipText() { - return `${_.escape(this.job.name)} - ${this.job.status.tooltip}`; + return `${this.job.name} - ${this.job.status.tooltip}`; }, }, }; @@ -36,7 +32,10 @@ export default { <template> <div class="build-job" - :class="{ retried: job.retried, active: isActive }" + :class="{ + retried: job.retried, + active: isActive + }" > <a v-tooltip diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js index 8aabb840847..1c98683c597 100644 --- a/app/assets/javascripts/mr_notes/index.js +++ b/app/assets/javascripts/mr_notes/index.js @@ -4,6 +4,7 @@ import { mapActions, mapState, mapGetters } from 'vuex'; import initDiffsApp from '../diffs'; import notesApp from '../notes/components/notes_app.vue'; import discussionCounter from '../notes/components/discussion_counter.vue'; +import initDiscussionFilters from '../notes/discussion_filters'; import store from './stores'; import MergeRequest from '../merge_request'; @@ -88,5 +89,6 @@ export default function initMrNotes() { }, }); + initDiscussionFilters(store); initDiffsApp(store); } diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue index ad6e7cf501d..1f80f24e045 100644 --- a/app/assets/javascripts/notes/components/discussion_counter.vue +++ b/app/assets/javascripts/notes/components/discussion_counter.vue @@ -56,10 +56,11 @@ export default { </script> <template> - <div class="line-resolve-all-container prepend-top-10"> + <div + v-if="discussionCount > 0" + class="line-resolve-all-container prepend-top-8"> <div> <div - v-if="discussionCount > 0" :class="{ 'has-next-btn': hasNextButton }" class="line-resolve-all"> <span diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue new file mode 100644 index 00000000000..27972682ca1 --- /dev/null +++ b/app/assets/javascripts/notes/components/discussion_filter.vue @@ -0,0 +1,82 @@ +<script> +import $ from 'jquery'; +import Icon from '~/vue_shared/components/icon.vue'; +import { mapGetters, mapActions } from 'vuex'; + +export default { + components: { + Icon, + }, + props: { + filters: { + type: Array, + required: true, + }, + defaultValue: { + type: Number, + default: null, + required: false, + }, + }, + data() { + return { currentValue: this.defaultValue }; + }, + computed: { + ...mapGetters([ + 'getNotesDataByProp', + ]), + currentFilter() { + if (!this.currentValue) return this.filters[0]; + return this.filters.find(filter => filter.value === this.currentValue); + }, + }, + methods: { + ...mapActions(['filterDiscussion']), + selectFilter(value) { + const filter = parseInt(value, 10); + + // close dropdown + $(this.$refs.dropdownToggle).dropdown('toggle'); + + if (filter === this.currentValue) return; + this.currentValue = filter; + this.filterDiscussion({ path: this.getNotesDataByProp('discussionsPath'), filter }); + }, + }, +}; +</script> + +<template> + <div class="discussion-filter-container d-inline-block align-bottom"> + <button + id="discussion-filter-dropdown" + ref="dropdownToggle" + class="btn btn-default" + data-toggle="dropdown" + aria-expanded="false" + > + {{ currentFilter.title }} + <icon name="chevron-down" /> + </button> + <div + class="dropdown-menu dropdown-menu-selectable dropdown-menu-right" + aria-labelledby="discussion-filter-dropdown"> + <div class="dropdown-content"> + <ul> + <li + v-for="filter in filters" + :key="filter.value" + > + <button + :class="{ 'is-active': filter.value === currentValue }" + type="button" + @click="selectFilter(filter.value)" + > + {{ filter.title }} + </button> + </li> + </ul> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 618a1581d8f..b0faa443a18 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -50,11 +50,11 @@ export default { }, data() { return { - isLoading: true, + currentFilter: null, }; }, computed: { - ...mapGetters(['isNotesFetched', 'discussions', 'getNotesDataByProp', 'discussionCount']), + ...mapGetters(['isNotesFetched', 'discussions', 'getNotesDataByProp', 'discussionCount', 'isLoading']), noteableType() { return this.noteableData.noteableType; }, @@ -102,6 +102,7 @@ export default { }, methods: { ...mapActions({ + setLoadingState: 'setLoadingState', fetchDiscussions: 'fetchDiscussions', poll: 'poll', actionToggleAward: 'toggleAward', @@ -133,19 +134,19 @@ export default { return discussion.individual_note ? { note: discussion.notes[0] } : { discussion }; }, fetchNotes() { - return this.fetchDiscussions(this.getNotesDataByProp('discussionsPath')) + return this.fetchDiscussions({ path: this.getNotesDataByProp('discussionsPath') }) .then(() => { this.initPolling(); }) .then(() => { - this.isLoading = false; + this.setLoadingState(false); this.setNotesFetchedState(true); eventHub.$emit('fetchedNotesData'); }) .then(() => this.$nextTick()) .then(() => this.checkLocationHash()) .catch(() => { - this.isLoading = false; + this.setLoadingState(false); this.setNotesFetchedState(true); Flash('Something went wrong while fetching comments. Please try again.'); }); diff --git a/app/assets/javascripts/notes/discussion_filters.js b/app/assets/javascripts/notes/discussion_filters.js new file mode 100644 index 00000000000..012ffc4093e --- /dev/null +++ b/app/assets/javascripts/notes/discussion_filters.js @@ -0,0 +1,33 @@ +import Vue from 'vue'; +import DiscussionFilter from './components/discussion_filter.vue'; + +export default (store) => { + const discussionFilterEl = document.getElementById('js-vue-discussion-filter'); + + if (discussionFilterEl) { + const { defaultFilter, notesFilters } = discussionFilterEl.dataset; + const defaultValue = defaultFilter ? parseInt(defaultFilter, 10) : null; + const filterValues = notesFilters ? JSON.parse(notesFilters) : {}; + const filters = Object.keys(filterValues).map(entry => + ({ title: entry, value: filterValues[entry] })); + + return new Vue({ + el: discussionFilterEl, + name: 'DiscussionFilter', + components: { + DiscussionFilter, + }, + store, + render(createElement) { + return createElement('discussion-filter', { + props: { + filters, + defaultValue, + }, + }); + }, + }); + } + + return null; +}; diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index 3aef30c608c..2f715c85fa6 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -1,10 +1,13 @@ import Vue from 'vue'; import notesApp from './components/notes_app.vue'; +import initDiscussionFilters from './discussion_filters'; import createStore from './stores'; document.addEventListener('DOMContentLoaded', () => { const store = createStore(); + initDiscussionFilters(store); + return new Vue({ el: '#js-vue-notes', components: { diff --git a/app/assets/javascripts/notes/services/notes_service.js b/app/assets/javascripts/notes/services/notes_service.js index f5dce94caad..47a6f07cce2 100644 --- a/app/assets/javascripts/notes/services/notes_service.js +++ b/app/assets/javascripts/notes/services/notes_service.js @@ -5,8 +5,9 @@ import * as constants from '../constants'; Vue.use(VueResource); export default { - fetchDiscussions(endpoint) { - return Vue.http.get(endpoint); + fetchDiscussions(endpoint, filter) { + const config = filter !== undefined ? { params: { notes_filter: filter } } : null; + return Vue.http.get(endpoint, config); }, deleteNote(endpoint) { return Vue.http.delete(endpoint); diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 7ab7e5a9abb..b5dd49bc6c9 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -11,6 +11,7 @@ import loadAwardsHandler from '../../awards_handler'; import sidebarTimeTrackingEventHub from '../../sidebar/event_hub'; import { isInViewport, scrollToElement } from '../../lib/utils/common_utils'; import mrWidgetEventHub from '../../vue_merge_request_widget/event_hub'; +import { __ } from '~/locale'; let eTagPoll; @@ -36,9 +37,9 @@ export const setNotesFetchedState = ({ commit }, state) => export const toggleDiscussion = ({ commit }, data) => commit(types.TOGGLE_DISCUSSION, data); -export const fetchDiscussions = ({ commit }, path) => +export const fetchDiscussions = ({ commit }, { path, filter }) => service - .fetchDiscussions(path) + .fetchDiscussions(path, filter) .then(res => res.json()) .then(discussions => { commit(types.SET_INITIAL_DISCUSSIONS, discussions); @@ -251,7 +252,7 @@ const pollSuccessCallBack = (resp, commit, state, getters, dispatch) => { if (discussion) { commit(types.ADD_NEW_REPLY_TO_DISCUSSION, note); } else if (note.type === constants.DIFF_NOTE) { - dispatch('fetchDiscussions', state.notesData.discussionsPath); + dispatch('fetchDiscussions', { path: state.notesData.discussionsPath }); } else { commit(types.ADD_NEW_NOTE, note); } @@ -345,5 +346,23 @@ export const updateMergeRequestWidget = () => { mrWidgetEventHub.$emit('mr.discussion.updated'); }; +export const setLoadingState = ({ commit }, data) => { + commit(types.SET_NOTES_LOADING_STATE, data); +}; + +export const filterDiscussion = ({ dispatch }, { path, filter }) => { + dispatch('setLoadingState', true); + dispatch('fetchDiscussions', { path, filter }) + .then(() => { + dispatch('setLoadingState', false); + dispatch('setNotesFetchedState', true); + }) + .catch(() => { + dispatch('setLoadingState', false); + dispatch('setNotesFetchedState', true); + Flash(__('Something went wrong while fetching comments. Please try again.')); + }); +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index a829149a17e..21c334a9d33 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -11,6 +11,8 @@ export const getNotesData = state => state.notesData; export const isNotesFetched = state => state.isNotesFetched; +export const isLoading = state => state.isLoading; + export const getNotesDataByProp = state => prop => state.notesData[prop]; export const getNoteableData = state => state.noteableData; diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js index 61dbb075586..400142668ea 100644 --- a/app/assets/javascripts/notes/stores/modules/index.js +++ b/app/assets/javascripts/notes/stores/modules/index.js @@ -11,6 +11,7 @@ export default () => ({ // View layer isToggleStateButtonLoading: false, isNotesFetched: false, + isLoading: true, // holds endpoints and permissions provided through haml notesData: { diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index 6f374f78691..2fa53aef1d4 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -14,6 +14,7 @@ export const UPDATE_NOTE = 'UPDATE_NOTE'; export const UPDATE_DISCUSSION = 'UPDATE_DISCUSSION'; export const SET_DISCUSSION_DIFF_LINES = 'SET_DISCUSSION_DIFF_LINES'; export const SET_NOTES_FETCHED_STATE = 'SET_NOTES_FETCHED_STATE'; +export const SET_NOTES_LOADING_STATE = 'SET_NOTES_LOADING_STATE'; // DISCUSSION export const COLLAPSE_DISCUSSION = 'COLLAPSE_DISCUSSION'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index 73e55705f39..65085452139 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -216,6 +216,10 @@ export default { Object.assign(state, { isNotesFetched: value }); }, + [types.SET_NOTES_LOADING_STATE](state, value) { + state.isLoading = value; + }, + [types.SET_DISCUSSION_DIFF_LINES](state, { discussionId, diffLines }) { const discussion = utils.findNoteObjectById(state.discussions, discussionId); diff --git a/app/assets/javascripts/pager.js b/app/assets/javascripts/pager.js index 3b58c54b3f4..386a9b2c740 100644 --- a/app/assets/javascripts/pager.js +++ b/app/assets/javascripts/pager.js @@ -7,14 +7,21 @@ const ENDLESS_SCROLL_BOTTOM_PX = 400; const ENDLESS_SCROLL_FIRE_DELAY_MS = 1000; export default { - init(limit = 0, preload = false, disable = false, prepareData = $.noop, callback = $.noop) { + init( + limit = 0, + preload = false, + disable = false, + prepareData = $.noop, + callback = $.noop, + container = '', + ) { this.url = $('.content_list').data('href') || removeParams(['limit', 'offset']); this.limit = limit; this.offset = parseInt(getParameterByName('offset'), 10) || this.limit; this.disable = disable; this.prepareData = prepareData; this.callback = callback; - this.loading = $('.loading').first(); + this.loading = $(`${container} .loading`).first(); if (preload) { this.offset = 0; this.getOld(); diff --git a/app/assets/javascripts/pages/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js index 1de9945baad..04bcb16f036 100644 --- a/app/assets/javascripts/pages/users/user_tabs.js +++ b/app/assets/javascripts/pages/users/user_tabs.js @@ -170,7 +170,7 @@ export default class UserTabs { this.loadActivityCalendar('activity'); // eslint-disable-next-line no-new - new Activities(); + new Activities('#activity'); this.loaded.activity = true; } diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index 0f95fb911e1..8ea34f5d19d 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -185,7 +185,17 @@ ul.related-merge-requests > li { } .new-branch-col { - padding-top: 10px; + font-size: 0; + + .discussion-filter-container { + &:not(:only-child) { + margin-right: $gl-padding-8; + } + + @include media-breakpoint-down(md) { + margin-top: $gl-padding-8; + } + } } .create-mr-dropdown-wrap { @@ -205,6 +215,10 @@ ul.related-merge-requests > li { .btn-group:not(.hidden) { display: flex; + + @include media-breakpoint-down(md) { + margin-top: $gl-padding-8; + } } .js-create-merge-request { @@ -251,7 +265,6 @@ ul.related-merge-requests > li { .new-branch-col { padding-top: 0; - text-align: right; align-self: center; } @@ -262,3 +275,9 @@ ul.related-merge-requests > li { } } } + +@include media-breakpoint-up(lg) { + .new-branch-col { + text-align: right; + } +} diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 2feb7464ecb..fa6afbf81de 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -818,9 +818,17 @@ display: flex; justify-content: space-between; - @include media-breakpoint-down(xs) { + @include media-breakpoint-down(md) { flex-direction: column-reverse; } + + .discussion-filter-container { + margin-top: $gl-padding-8; + + &:not(:only-child) { + padding-right: $gl-padding-8; + } + } } .limit-container-width:not(.container-limited) { diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index bfba1bf1b2b..be535ade0a6 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -618,7 +618,6 @@ ul.notes { .line-resolve-all-container { @include notes-media('min', map-get($grid-breakpoints, sm)) { margin-right: 0; - padding-left: $gl-padding; } > div { @@ -756,3 +755,23 @@ ul.notes { margin-top: 4px; } } + +.discussion-filter-container { + + .btn > svg { + width: $gl-col-padding; + height: $gl-col-padding; + } + + .dropdown-menu { + margin-bottom: $gl-padding-4; + + @include media-breakpoint-down(md) { + margin-left: $btn-side-margin + $contextual-sidebar-collapsed-width; + } + + @include media-breakpoint-down(xs) { + margin-left: $btn-side-margin; + } + } +} diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 07e01e903ea..ad9cc0925b7 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -2,6 +2,7 @@ module IssuableActions extend ActiveSupport::Concern + include Gitlab::Utils::StrongMemoize included do before_action :labels, only: [:show, :new, :edit] @@ -95,10 +96,14 @@ module IssuableActions def discussions notes = issuable.discussion_notes .inc_relations_for_view + .with_notes_filter(notes_filter) .includes(:noteable) .fresh - notes = ResourceEvents::MergeIntoNotesService.new(issuable, current_user).execute(notes) + if notes_filter != UserPreference::NOTES_FILTERS[:only_comments] + notes = ResourceEvents::MergeIntoNotesService.new(issuable, current_user).execute(notes) + end + notes = prepare_notes_for_rendering(notes) notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) } @@ -110,6 +115,32 @@ module IssuableActions private + def notes_filter + strong_memoize(:notes_filter) do + notes_filter_param = params[:notes_filter]&.to_i + + # GitLab Geo does not expect database UPDATE or INSERT statements to happen + # on GET requests. + # This is just a fail-safe in case notes_filter is sent via GET request in GitLab Geo. + if Gitlab::Database.read_only? + notes_filter_param || current_user&.notes_filter_for(issuable) + else + notes_filter = current_user&.set_notes_filter(notes_filter_param, issuable) || notes_filter_param + + # We need to invalidate the cache for polling notes otherwise it will + # ignore the filter. + # The ideal would be to invalidate the cache for each user. + issuable.expire_note_etag_cache if notes_filter_updated? + + notes_filter + end + end + end + + def notes_filter_updated? + current_user&.user_preference&.previous_changes&.any? + end + def discussion_serializer DiscussionSerializer.new(project: project, noteable: issuable, current_user: current_user, note_entity: ProjectNoteEntity) end diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index 3a45d6205ab..777b147e2dd 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -17,10 +17,17 @@ module NotesActions notes_json = { notes: [], last_fetched_at: current_fetched_at } - notes = notes_finder.execute - .inc_relations_for_view + notes = notes_finder + .execute + .inc_relations_for_view + + if notes_filter != UserPreference::NOTES_FILTERS[:only_comments] + notes = + ResourceEvents::MergeIntoNotesService + .new(noteable, current_user, last_fetched_at: current_fetched_at) + .execute(notes) + end - notes = ResourceEvents::MergeIntoNotesService.new(noteable, current_user, last_fetched_at: current_fetched_at).execute(notes) notes = prepare_notes_for_rendering(notes) notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) } @@ -224,6 +231,10 @@ module NotesActions request.headers['X-Last-Fetched-At'] end + def notes_filter + current_user&.notes_filter_for(params[:target_type]) + end + def notes_finder @notes_finder ||= NotesFinder.new(project, current_user, finder_params) end diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb index 4bac763d000..3152a38fd8e 100644 --- a/app/controllers/projects/notes_controller.rb +++ b/app/controllers/projects/notes_controller.rb @@ -68,7 +68,7 @@ class Projects::NotesController < Projects::ApplicationController alias_method :awardable, :note def finder_params - params.merge(last_fetched_at: last_fetched_at) + params.merge(last_fetched_at: last_fetched_at, notes_filter: notes_filter) end def authorize_admin_note! diff --git a/app/finders/notes_finder.rb b/app/finders/notes_finder.rb index c67c2065440..817aac8b5d5 100644 --- a/app/finders/notes_finder.rb +++ b/app/finders/notes_finder.rb @@ -24,6 +24,8 @@ class NotesFinder def execute notes = init_collection notes = since_fetch_at(notes) + notes = notes.with_notes_filter(@params[:notes_filter]) if notes_filter? + notes.fresh end @@ -134,4 +136,8 @@ class NotesFinder last_fetched_at = Time.at(@params.fetch(:last_fetched_at, 0).to_i) notes.updated_after(last_fetched_at - FETCH_OVERLAP) end + + def notes_filter? + @params[:notes_filter].present? + end end diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index f573fd399a5..0c313e9e6d3 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -1,6 +1,15 @@ # frozen_string_literal: true module GroupsHelper + def group_overview_nav_link_paths + %w[ + groups#show + groups#activity + groups#subgroups + analytics#show + ] + end + def group_nav_link_paths %w[groups#projects groups#edit badges#index ci_cd#show ldap_group_links#index hooks#index audit_events#index pipeline_quota#index] end diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 97406fefd43..6069640b9c8 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -386,8 +386,8 @@ module IssuablesHelper { todo_text: "Add todo", mark_text: "Mark todo as done", - todo_icon: (is_collapsed ? icon('plus-square') : nil), - mark_icon: (is_collapsed ? icon('check-square', class: 'todo-undone') : nil), + todo_icon: (is_collapsed ? sprite_icon('todo-add') : nil), + mark_icon: (is_collapsed ? sprite_icon('todo-done', css_class: 'todo-undone') : nil), issuable_id: issuable.id, issuable_type: issuable.class.name.underscore, url: project_todos_path(@project), diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 2b28b702b05..34a889057ab 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -19,7 +19,9 @@ module Ci sast: 'gl-sast-report.json', dependency_scanning: 'gl-dependency-scanning-report.json', container_scanning: 'gl-container-scanning-report.json', - dast: 'gl-dast-report.json' + dast: 'gl-dast-report.json', + license_management: 'gl-license-management-report.json', + performance: 'performance.json' }.freeze TYPE_AND_FORMAT_PAIRS = { @@ -35,7 +37,9 @@ module Ci sast: :raw, dependency_scanning: :raw, container_scanning: :raw, - dast: :raw + dast: :raw, + license_management: :raw, + performance: :raw }.freeze belongs_to :project @@ -80,7 +84,9 @@ module Ci dependency_scanning: 6, ## EE-specific container_scanning: 7, ## EE-specific dast: 8, ## EE-specific - codequality: 9 ## EE-specific + codequality: 9, ## EE-specific + license_management: 10, ## EE-specific + performance: 11 ## EE-specific } enum file_format: { diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index 43bf852c7ec..b311f5e0617 100644 --- a/app/models/clusters/applications/runner.rb +++ b/app/models/clusters/applications/runner.rb @@ -3,7 +3,7 @@ module Clusters module Applications class Runner < ActiveRecord::Base - VERSION = '0.1.34'.freeze + VERSION = '0.1.35'.freeze self.table_name = 'clusters_applications_runners' diff --git a/app/models/note.rb b/app/models/note.rb index 95e1d3afa00..e1bd943e8e4 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -110,6 +110,15 @@ class Note < ActiveRecord::Base :system_note_metadata, :note_diff_file) end + scope :with_notes_filter, -> (notes_filter) do + case notes_filter + when UserPreference::NOTES_FILTERS[:only_comments] + user + else + all + end + end + scope :diff_notes, -> { where(type: %w(LegacyDiffNote DiffNote)) } scope :new_diff_notes, -> { where(type: 'DiffNote') } scope :non_diff_notes, -> { where(type: ['Note', 'DiscussionNote', nil]) } diff --git a/app/models/project.rb b/app/models/project.rb index be99408fcea..382fb4f463a 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -548,6 +548,8 @@ class Project < ActiveRecord::Base self[:lfs_enabled] && Gitlab.config.lfs.enabled end + alias_method :lfs_enabled, :lfs_enabled? + def auto_devops_enabled? if auto_devops&.enabled.nil? has_auto_devops_implicitly_enabled? diff --git a/app/models/user.rb b/app/models/user.rb index 34efb22b359..ca7fc3b058f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -152,6 +152,7 @@ class User < ActiveRecord::Base belongs_to :accepted_term, class_name: 'ApplicationSetting::Term' has_one :status, class_name: 'UserStatus' + has_one :user_preference # # Validations @@ -224,6 +225,8 @@ class User < ActiveRecord::Base enum project_view: [:readme, :activity, :files] delegate :path, to: :namespace, allow_nil: true, prefix: true + delegate :notes_filter_for, to: :user_preference + delegate :set_notes_filter, to: :user_preference state_machine :state, initial: :active do event :block do @@ -1367,6 +1370,11 @@ class User < ActiveRecord::Base !consented_usage_stats? && 7.days.ago > self.created_at && !has_current_license? && User.single_user? end + # Avoid migrations only building user preference object when needed. + def user_preference + super.presence || build_user_preference + end + def todos_limited_to(ids) todos.where(id: ids) end diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb new file mode 100644 index 00000000000..6cd91abc261 --- /dev/null +++ b/app/models/user_preference.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class UserPreference < ActiveRecord::Base + # We could use enums, but Rails 4 doesn't support multiple + # enum options with same name for multiple fields, also it creates + # extra methods that aren't really needed here. + NOTES_FILTERS = { all_notes: 0, only_comments: 1 }.freeze + + belongs_to :user + + validates :issue_notes_filter, :merge_request_notes_filter, inclusion: { in: NOTES_FILTERS.values }, presence: true + + class << self + def notes_filters + { + s_('Notes|Show all activity') => NOTES_FILTERS[:all_notes], + s_('Notes|Show comments only') => NOTES_FILTERS[:only_comments] + } + end + end + + def set_notes_filter(filter_id, issuable) + # No need to update the column if the value is already set. + if filter_id && NOTES_FILTERS.values.include?(filter_id) + field = notes_filter_field_for(issuable) + self[field] = filter_id + + save if attribute_changed?(field) + end + + notes_filter_for(issuable) + end + + # Returns the current discussion filter for a given issuable + # or issuable type. + def notes_filter_for(resource) + self[notes_filter_field_for(resource)] + end + + private + + def notes_filter_field_for(resource) + field_key = + if resource.is_a?(Issuable) + resource.model_name.param_key + else + resource + end + + "#{field_key}_notes_filter" + end +end diff --git a/app/serializers/current_user_entity.rb b/app/serializers/current_user_entity.rb new file mode 100644 index 00000000000..71d14e727dd --- /dev/null +++ b/app/serializers/current_user_entity.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Always use this entity when rendering data for current user +# for attributes that does not need to be visible to other users +# like user preferences. +class CurrentUserEntity < UserEntity + expose :user_preference, using: UserPreferenceEntity +end diff --git a/app/serializers/merge_request_user_entity.rb b/app/serializers/merge_request_user_entity.rb index fd2d2897113..53257b0602c 100644 --- a/app/serializers/merge_request_user_entity.rb +++ b/app/serializers/merge_request_user_entity.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class MergeRequestUserEntity < UserEntity +class MergeRequestUserEntity < CurrentUserEntity include RequestAwareEntity include BlobHelper include TreeHelper diff --git a/app/serializers/user_preference_entity.rb b/app/serializers/user_preference_entity.rb new file mode 100644 index 00000000000..fbdaab459b3 --- /dev/null +++ b/app/serializers/user_preference_entity.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class UserPreferenceEntity < Grape::Entity + expose :issue_notes_filter + expose :merge_request_notes_filter + + expose :notes_filters do |user_preference| + UserPreference.notes_filters + end +end diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml index 4aa22138498..163556f4509 100644 --- a/app/views/layouts/nav/sidebar/_group.html.haml +++ b/app/views/layouts/nav/sidebar/_group.html.haml @@ -12,7 +12,7 @@ = @group.name %ul.sidebar-top-level-items.qa-group-sidebar - if group_sidebar_link?(:overview) - = nav_link(path: ['groups#show', 'groups#activity', 'groups#subgroups', 'analytics#show'], html_options: { class: 'home' }) do + = nav_link(path: group_overview_nav_link_paths, html_options: { class: 'home' }) do = link_to group_path(@group) do .nav-icon-container = sprite_icon('home') @@ -36,6 +36,16 @@ %span = _('Activity') + = render_if_exists 'groups/sidebar/security_dashboard' + + - if group_sidebar_link?(:contribution_analytics) + = nav_link(path: 'analytics#show') do + = link_to group_analytics_path(@group), title: 'Contribution Analytics', data: {placement: 'right'} do + %span + Contribution Analytics + + = render_if_exists "layouts/nav/ee/epic_link", group: @group + - if group_sidebar_link?(:issues) = nav_link(path: issues_sub_menu_items) do = link_to issues_group_path(@group) do @@ -132,4 +142,6 @@ %span = _('CI / CD') + = render_if_exists "groups/ee/settings_nav" + = render 'shared/sidebar_toggle_button' diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml index 28998acdc13..4917f4b8903 100644 --- a/app/views/projects/issues/_discussion.html.haml +++ b/app/views/projects/issues/_discussion.html.haml @@ -10,4 +10,4 @@ noteable_data: serialize_issuable(@issue), noteable_type: 'Issue', target_type: 'issue', - current_user_data: UserSerializer.new.represent(current_user, only_path: true).to_json } } + current_user_data: UserSerializer.new.represent(current_user, {only_path: true}, CurrentUserEntity).to_json } } diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml index a678cb6f058..5374f4a1de0 100644 --- a/app/views/projects/issues/_new_branch.html.haml +++ b/app/views/projects/issues/_new_branch.html.haml @@ -8,12 +8,13 @@ - create_branch_path = project_branches_path(@project, branch_name: @issue.to_branch_name, ref: @project.default_branch, issue_iid: @issue.iid) - refs_path = refs_namespace_project_path(@project.namespace, @project, search: '') - .create-mr-dropdown-wrap{ data: { can_create_path: can_create_path, create_mr_path: create_mr_path, create_branch_path: create_branch_path, refs_path: refs_path } } + .create-mr-dropdown-wrap.d-inline-block{ data: { can_create_path: can_create_path, create_mr_path: create_mr_path, create_branch_path: create_branch_path, refs_path: refs_path } } .btn-group.unavailable %button.btn.btn-grouped{ type: 'button', disabled: 'disabled' } = icon('spinner', class: 'fa-spin') %span.text Checking branch availability… + .btn-group.available.hidden %button.btn.js-create-merge-request.btn-success.btn-inverted{ type: 'button', data: { action: data_action } } = value diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index c39fd0063be..b50b3ca207b 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -77,11 +77,12 @@ #related-branches{ data: { url: related_branches_project_issue_path(@project, @issue) } } // This element is filled in using JavaScript. - .content-block.emoji-block + .content-block.emoji-block.emoji-block-sticky .row - .col-sm-8.js-noteable-awards + .col-md-12.col-lg-6.js-noteable-awards = render 'award_emoji/awards_block', awardable: @issue, inline: true - .col-sm-4.new-branch-col + .col-md-12.col-lg-6.new-branch-col + #js-vue-discussion-filter{ data: { default_filter: current_user&.notes_filter_for(@issue), notes_filters: UserPreference.notes_filters.to_json } } = render 'new_branch' unless @issue.confidential? %section.issuable-discussion diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index ef2fa8668c0..efc2d88172e 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -51,8 +51,10 @@ = tab_link_for @merge_request, :diffs do Changes %span.badge.badge-pill= @merge_request.diff_size - - #js-vue-discussion-counter + .d-inline-flex.flex-wrap + #js-vue-discussion-filter{ data: { default_filter: current_user&.notes_filter_for(@merge_request), + notes_filters: UserPreference.notes_filters.to_json } } + #js-vue-discussion-counter .tab-content#diff-notes-app #notes.notes.tab-pane.voting_notes diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index c4d177361e7..cb45928d9a5 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -36,7 +36,7 @@ %button.btn.btn-link{ type: 'button' } = sprite_icon('search') %span - Press Enter or click to search + = _('Press Enter or click to search') %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } %li.filter-dropdown-item %button.btn.btn-link{ type: 'button' } @@ -61,7 +61,7 @@ %ul{ data: { dropdown: true } } %li.filter-dropdown-item{ data: { value: 'none' } } %button.btn.btn-link{ type: 'button' } - No Assignee + = _('No Assignee') %li.divider.droplab-item-ignore - if current_user = render 'shared/issuable/user_dropdown_item', @@ -74,13 +74,16 @@ %ul{ data: { dropdown: true } } %li.filter-dropdown-item{ data: { value: 'none' } } %button.btn.btn-link{ type: 'button' } - No Milestone + = _('None') + %li.filter-dropdown-item{ data: { value: 'any' } } + %button.btn.btn-link{ type: 'button' } + = _('Any') %li.filter-dropdown-item{ data: { value: 'upcoming' } } %button.btn.btn-link{ type: 'button' } - Upcoming + = _('Upcoming') %li.filter-dropdown-item{ 'data-value' => 'started' } %button.btn.btn-link{ type: 'button' } - Started + = _('Started') %li.divider.droplab-item-ignore %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } %li.filter-dropdown-item @@ -90,7 +93,7 @@ %ul{ data: { dropdown: true } } %li.filter-dropdown-item{ data: { value: 'none' } } %button.btn.btn-link{ type: 'button' } - No Label + = _('No Label') %li.divider.droplab-item-ignore %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } %li.filter-dropdown-item diff --git a/app/views/shared/issuable/_sidebar_todo.html.haml b/app/views/shared/issuable/_sidebar_todo.html.haml index 583b33a8a1b..660ee6d5777 100644 --- a/app/views/shared/issuable/_sidebar_todo.html.haml +++ b/app/views/shared/issuable/_sidebar_todo.html.haml @@ -1,6 +1,6 @@ - is_collapsed = local_assigns.fetch(:is_collapsed, false) -- mark_content = is_collapsed ? icon('check-square', class: 'todo-undone') : _('Mark todo as done') -- todo_content = is_collapsed ? icon('plus-square') : _('Add todo') +- mark_content = is_collapsed ? sprite_icon('todo-done', css_class: 'todo-undone') : _('Mark todo as done') +- todo_content = is_collapsed ? sprite_icon('todo-add') : _('Add todo') %button.issuable-todo-btn.js-issuable-todo{ type: 'button', class: (is_collapsed ? 'btn-blank sidebar-collapsed-icon dont-change-state has-tooltip' : 'btn btn-default issuable-header-btn float-right'), diff --git a/changelogs/unreleased/26723-discussion-filters.yml b/changelogs/unreleased/26723-discussion-filters.yml new file mode 100644 index 00000000000..3abe95bf30d --- /dev/null +++ b/changelogs/unreleased/26723-discussion-filters.yml @@ -0,0 +1,5 @@ +--- +title: Filter notes by comments or activity for issues and merge requests +merge_request: +author: +type: added diff --git a/changelogs/unreleased/32959-update-todo-icon.yml b/changelogs/unreleased/32959-update-todo-icon.yml new file mode 100644 index 00000000000..f08fd6aa89f --- /dev/null +++ b/changelogs/unreleased/32959-update-todo-icon.yml @@ -0,0 +1,5 @@ +--- +title: Update Todo icons in collapsed sidebar for Issues and MRs +merge_request: 22534 +author: +type: changed diff --git a/changelogs/unreleased/52059-filter-milestone-by-none-any.yml b/changelogs/unreleased/52059-filter-milestone-by-none-any.yml new file mode 100644 index 00000000000..5511440c0b9 --- /dev/null +++ b/changelogs/unreleased/52059-filter-milestone-by-none-any.yml @@ -0,0 +1,5 @@ +--- +title: Added `Any` option to milestones filter +merge_request: 22351 +author: Heinrich Lee Yu +type: added diff --git a/changelogs/unreleased/53013-duplicate-escape.yml b/changelogs/unreleased/53013-duplicate-escape.yml new file mode 100644 index 00000000000..c5ec2322fb5 --- /dev/null +++ b/changelogs/unreleased/53013-duplicate-escape.yml @@ -0,0 +1,5 @@ +--- +title: Remove duplicate escape in job sidebar +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/53023-endless-scroll-loader-is-visible-on-user-profile-overview-page.yml b/changelogs/unreleased/53023-endless-scroll-loader-is-visible-on-user-profile-overview-page.yml new file mode 100644 index 00000000000..0377e10fe9e --- /dev/null +++ b/changelogs/unreleased/53023-endless-scroll-loader-is-visible-on-user-profile-overview-page.yml @@ -0,0 +1,4 @@ +title: Adds container to pager to enable scoping +merge_request: 22529 +? author +type: other diff --git a/changelogs/unreleased/add-role-binding-to-kubeclient.yml b/changelogs/unreleased/add-role-binding-to-kubeclient.yml new file mode 100644 index 00000000000..bc343116eb4 --- /dev/null +++ b/changelogs/unreleased/add-role-binding-to-kubeclient.yml @@ -0,0 +1,5 @@ +--- +title: Allow kubeclient to call RoleBinding methods +merge_request: 22524 +author: +type: other diff --git a/changelogs/unreleased/lfs-project-attribute-alias.yml b/changelogs/unreleased/lfs-project-attribute-alias.yml new file mode 100644 index 00000000000..883869f651a --- /dev/null +++ b/changelogs/unreleased/lfs-project-attribute-alias.yml @@ -0,0 +1,5 @@ +--- +title: Resolve LFS not correctly showing enabled +merge_request: 22501 +author: +type: fixed diff --git a/changelogs/unreleased/support-license-management-and-performance.yml b/changelogs/unreleased/support-license-management-and-performance.yml new file mode 100644 index 00000000000..2e65dba5e76 --- /dev/null +++ b/changelogs/unreleased/support-license-management-and-performance.yml @@ -0,0 +1,5 @@ +--- +title: Support licenses and performance +merge_request: +author: +type: added diff --git a/changelogs/unreleased/update-runner-chart-to-0-1-35.yml b/changelogs/unreleased/update-runner-chart-to-0-1-35.yml new file mode 100644 index 00000000000..3b8029c8d96 --- /dev/null +++ b/changelogs/unreleased/update-runner-chart-to-0-1-35.yml @@ -0,0 +1,5 @@ +--- +title: Update used version of Runner Helm Chart to 0.1.35 +merge_request: 22541 +author: +type: other diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 749cdd0f869..a4db125f831 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -772,9 +772,6 @@ test: default: path: tmp/tests/repositories/ gitaly_address: unix:tmp/tests/gitaly/gitaly.socket - broken: - path: tmp/tests/non-existent-repositories - gitaly_address: unix:tmp/tests/gitaly/gitaly.socket gitaly: client_path: tmp/tests/gitaly diff --git a/db/migrate/20180925200829_create_user_preferences.rb b/db/migrate/20180925200829_create_user_preferences.rb new file mode 100644 index 00000000000..755cabdabde --- /dev/null +++ b/db/migrate/20180925200829_create_user_preferences.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class CreateUserPreferences < ActiveRecord::Migration + DOWNTIME = false + + class UserPreference < ActiveRecord::Base + self.table_name = 'user_preferences' + + NOTES_FILTERS = { all_notes: 0, comments: 1 }.freeze + end + + def change + create_table :user_preferences do |t| + t.references :user, + null: false, + index: { unique: true }, + foreign_key: { on_delete: :cascade } + + t.integer :issue_notes_filter, + default: UserPreference::NOTES_FILTERS[:all_notes], + null: false, limit: 2 + + t.integer :merge_request_notes_filter, + default: UserPreference::NOTES_FILTERS[:all_notes], + null: false, + limit: 2 + + t.timestamps_with_timezone null: false + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 50989960aa9..ddfccbba678 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -2134,6 +2134,16 @@ ActiveRecord::Schema.define(version: 20181013005024) do add_index "user_interacted_projects", ["project_id", "user_id"], name: "index_user_interacted_projects_on_project_id_and_user_id", unique: true, using: :btree add_index "user_interacted_projects", ["user_id"], name: "index_user_interacted_projects_on_user_id", using: :btree + create_table "user_preferences", force: :cascade do |t| + t.integer "user_id", null: false + t.integer "issue_notes_filter", limit: 2, default: 0, null: false + t.integer "merge_request_notes_filter", limit: 2, default: 0, null: false + t.datetime_with_timezone "created_at", null: false + t.datetime_with_timezone "updated_at", null: false + end + + add_index "user_preferences", ["user_id"], name: "index_user_preferences_on_user_id", unique: true, using: :btree + create_table "user_statuses", primary_key: "user_id", force: :cascade do |t| t.integer "cached_markdown_version" t.string "emoji", default: "speech_balloon", null: false @@ -2460,6 +2470,7 @@ ActiveRecord::Schema.define(version: 20181013005024) do add_foreign_key "user_custom_attributes", "users", on_delete: :cascade add_foreign_key "user_interacted_projects", "projects", name: "fk_722ceba4f7", on_delete: :cascade add_foreign_key "user_interacted_projects", "users", name: "fk_0894651f08", on_delete: :cascade + add_foreign_key "user_preferences", "users", on_delete: :cascade add_foreign_key "user_statuses", "users", on_delete: :cascade add_foreign_key "user_synced_attributes_metadata", "users", on_delete: :cascade add_foreign_key "users", "application_setting_terms", column: "accepted_term_id", name: "fk_789cd90b35", on_delete: :cascade diff --git a/doc/administration/pages/index.md b/doc/administration/pages/index.md index 2952a98626a..d8345f2d6bd 100644 --- a/doc/administration/pages/index.md +++ b/doc/administration/pages/index.md @@ -242,6 +242,33 @@ verification requirement. Navigate to `Admin area ➔ Settings` and uncheck **Require users to prove ownership of custom domains** in the Pages section. This setting is enabled by default. +### Access control + +Access control was [introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/33422) +in GitLab 11.5. It can be configured per-project, and allows access to a Pages +site to be controlled based on a user's membership to that project. + +Access control works by registering the Pages daemon as an OAuth application +with GitLab. Whenever a request to access a private Pages site is made by an +unauthenticated user, the Pages daemon redirects the user to GitLab. If +authentication is successful, the user is redirected back to Pages with a token, +which is persisted in a cookie. The cookies are signed with a secret key, so +tampering can be detected. + +Each request to view a resource in a private site is authenticated by Pages +using that token. For each request it receives, it makes a request to the GitLab +API to check that the user is authorized to read that site. + +Pages access control is currently disabled by default. To enable it, you must: + +1. Enable it in `/etc/gitlab/gitlab.rb` + + ```ruby + gitlab_pages['access_control'] = true + ``` + +1. [Reconfigure GitLab][reconfigure] + ## Activate verbose logging for daemon Verbose logging was [introduced](https://gitlab.com/gitlab-org/omnibus-gitlab/merge_requests/2533) in diff --git a/doc/administration/pages/source.md b/doc/administration/pages/source.md index 295905a7625..ddff54be575 100644 --- a/doc/administration/pages/source.md +++ b/doc/administration/pages/source.md @@ -391,6 +391,44 @@ the first one with a backslash (\). For example `pages.example.io` would be: server_name ~^.*\.pages\.example\.io$; ``` +## Access control + +Access control was [introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/33422) +in GitLab 11.5. It can be configured per-project, and allows access to a Pages +site to be controlled based on a user's membership to that project. + +Access control works by registering the Pages daemon as an OAuth application +with GitLab. Whenever a request to access a private Pages site is made by an +unauthenticated user, the Pages daemon redirects the user to GitLab. If +authentication is successful, the user is redirected back to Pages with a token, +which is persisted in a cookie. The cookies are signed with a secret key, so +tampering can be detected. + +Each request to view a resource in a private site is authenticated by Pages +using that token. For each request it receives, it makes a request to the GitLab +API to check that the user is authorized to read that site. + +Pages access control is currently disabled by default. To enable it, you must: + +1. Modify your `config/gitlab.yml` file: + ```yaml + pages: + access_control: true + ``` +1. [Restart GitLab][restart] +1. Create a new [system OAuth application](../../integration/oauth_provider.md#adding-an-application-through-the-profile) + This should be called `GitLab Pages` and have a `Redirect URL` of + `https://projects.example.io/auth`. It does not need to be a "trusted" + application, but it does need the "api" scope. +1. Start the Pages daemon with the following additional arguments: + + ```shell + -auth-client-secret <OAuth code generated by GitLab> \ + -auth-redirect-uri http://projects.example.io/auth \ + -auth-secret <40 random hex characters> \ + -auth-server <URL of the GitLab instance> + ``` + ## Change storage path Follow the steps below to change the default path where GitLab Pages' contents diff --git a/doc/ci/runners/README.md b/doc/ci/runners/README.md index 83e0fa34ad6..2a179bfbbf0 100644 --- a/doc/ci/runners/README.md +++ b/doc/ci/runners/README.md @@ -312,7 +312,7 @@ We're always looking for contributions that can mitigate these If you think that registration token for a Project was revealed, you should reset them. It's recommended because such token can be used to register another -Runner to thi Project. It may be next used to obtain the values of secret +Runner to the Project. It may be next used to obtain the values of secret variables or clone the project code, that normally may be unavailable for the attacker. diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 424e1af7ba3..4b2a6ccc7e4 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -2031,3 +2031,5 @@ CI with various languages. [ce-12909]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/12909 [schedules]: ../../user/project/pipelines/schedules.md [variables-expressions]: ../variables/README.md#variables-expressions +[ee]: https://about.gitlab.com/gitlab-ee/ +[gitlab-versions]: https://about.gitlab.com/products/
\ No newline at end of file diff --git a/doc/development/feature_flags.md b/doc/development/feature_flags.md index 0f1f079bdb4..350593cc813 100644 --- a/doc/development/feature_flags.md +++ b/doc/development/feature_flags.md @@ -112,3 +112,8 @@ feature flag. You can stub a feature flag as follows: ```ruby stub_feature_flags(my_feature_flag: false) ``` + +## Enabling a feature flag + +Check how to [roll out changes using feature flags](rolling_out_changes_using_feature_flags.md). + diff --git a/doc/user/project/milestones/index.md b/doc/user/project/milestones/index.md index 632253db94c..3cf46231a9d 100644 --- a/doc/user/project/milestones/index.md +++ b/doc/user/project/milestones/index.md @@ -68,7 +68,8 @@ From [project issue boards](../issue_board.md), you can filter by both group mil When filtering by milestone, in addition to choosing a specific project milestone or group milestone, you can choose a special milestone filter. -- **No Milestone**: Show issues or merge requests with no assigned milestone. +- **None**: Show issues or merge requests with no assigned milestone. +- **Any**: Show issues or merge requests that have an assigned milestone. - **Upcoming**: Show issues or merge requests that have been assigned the open milestone that has the next upcoming due date (i.e. nearest due date in the future). - **Started**: Show issues or merge requests that have an assigned milestone with a start date that is before today. diff --git a/doc/user/project/repository/branches/index.md b/doc/user/project/repository/branches/index.md index e1d8345f415..783081cec26 100644 --- a/doc/user/project/repository/branches/index.md +++ b/doc/user/project/repository/branches/index.md @@ -30,12 +30,12 @@ to learn more. ## Delete merged branches -> [Introduced][ce-6449] in GitLab 8.14. +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/6449) in GitLab 8.14. ![Delete merged branches](img/delete_merged_branches.png) This feature allows merged branches to be deleted in bulk. Only branches that -have been merged and [are not protected][protected] will be deleted as part of +have been merged and [are not protected](../../protected_branches.md) will be deleted as part of this operation. It's particularly useful to clean up old branches that were not deleted @@ -44,7 +44,7 @@ automatically when a merge request was merged. ## Branch filter search box -> [Introduced][https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/22166] in GitLab 11.5. +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/22166) in GitLab 11.5. ![Branch filter search box](img/branch_filter_search_box.png) @@ -57,6 +57,3 @@ Sometimes when you have hundreds of branches you may want a more flexible matchi - `^feature` will only match branch names that begin with 'feature'. - `feature$` will only match branch names that end with 'feature'. - -[ce-6449]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/6449 "Add button to delete all merged branches" -[protected]: ../../protected_branches.md diff --git a/lib/gitlab/ci/config/entry/reports.rb b/lib/gitlab/ci/config/entry/reports.rb index 98f12c226b3..3ac2a6fa777 100644 --- a/lib/gitlab/ci/config/entry/reports.rb +++ b/lib/gitlab/ci/config/entry/reports.rb @@ -11,7 +11,7 @@ module Gitlab include Validatable include Attributable - ALLOWED_KEYS = %i[junit codequality sast dependency_scanning container_scanning dast].freeze + ALLOWED_KEYS = %i[junit codequality sast dependency_scanning container_scanning dast performance license_management].freeze attributes ALLOWED_KEYS @@ -26,6 +26,8 @@ module Gitlab validates :dependency_scanning, array_of_strings_or_string: true validates :container_scanning, array_of_strings_or_string: true validates :dast, array_of_strings_or_string: true + validates :performance, array_of_strings_or_string: true + validates :license_management, array_of_strings_or_string: true end end diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb index 30541ee3553..a17f27a3147 100644 --- a/lib/gitlab/database/migration_helpers.rb +++ b/lib/gitlab/database/migration_helpers.rb @@ -937,7 +937,7 @@ database (#{dbname}) using a super user and running: For MySQL you instead need to run: - GRANT ALL PRIVILEGES ON *.* TO #{user}@'%' + GRANT ALL PRIVILEGES ON #{dbname}.* TO #{user}@'%' Both queries will grant the user super user permissions, ensuring you don't run into similar problems in the future (e.g. when new tables are created). diff --git a/lib/gitlab/ee_compat_check.rb b/lib/gitlab/ee_compat_check.rb index 6fc86925f81..5d9ecd651a0 100644 --- a/lib/gitlab/ee_compat_check.rb +++ b/lib/gitlab/ee_compat_check.rb @@ -286,7 +286,7 @@ module Gitlab end def patch_name_from_branch(branch_name) - branch_name.parameterize << '.patch' + "#{branch_name.parameterize}.patch" end def patch_url @@ -434,9 +434,11 @@ module Gitlab end def conflicting_files_msg - failed_files.reduce("The conflicts detected were as follows:\n") do |memo, file| - memo << "\n - #{file}" - end + header = "The conflicts detected were as follows:\n" + separator = "\n - " + failed_items = failed_files.join(separator) + + "#{header}#{separator}#{failed_items}" end end end diff --git a/lib/gitlab/kubernetes/kube_client.rb b/lib/gitlab/kubernetes/kube_client.rb index 588238de608..e88a15b8acd 100644 --- a/lib/gitlab/kubernetes/kube_client.rb +++ b/lib/gitlab/kubernetes/kube_client.rb @@ -45,6 +45,13 @@ module Gitlab :update_cluster_role_binding, to: :rbac_client + # RBAC methods delegates to the apis/rbac.authorization.k8s.io api + # group client + delegate :create_role_binding, + :get_role_binding, + :update_role_binding, + to: :rbac_client + # Deployments resource is currently on the apis/extensions api group delegate :get_deployments, to: :extensions_client diff --git a/lib/gitlab/kubernetes/role_binding.rb b/lib/gitlab/kubernetes/role_binding.rb new file mode 100644 index 00000000000..4f3ee040bf2 --- /dev/null +++ b/lib/gitlab/kubernetes/role_binding.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Gitlab + module Kubernetes + class RoleBinding + attr_reader :role_name, :namespace, :service_account_name + + def initialize(role_name:, namespace:, service_account_name:) + @role_name = role_name + @namespace = namespace + @service_account_name = service_account_name + end + + def generate + ::Kubeclient::Resource.new.tap do |resource| + resource.metadata = metadata + resource.roleRef = role_ref + resource.subjects = subjects + end + end + + private + + def metadata + { name: "gitlab-#{namespace}", namespace: namespace } + end + + def role_ref + { + apiGroup: 'rbac.authorization.k8s.io', + kind: 'Role', + name: role_name + } + end + + def subjects + [ + { + kind: 'ServiceAccount', + name: service_account_name, + namespace: namespace + } + ] + end + end + end +end diff --git a/lib/gitlab/setup_helper.rb b/lib/gitlab/setup_helper.rb index 4a745147858..2b7e12639be 100644 --- a/lib/gitlab/setup_helper.rb +++ b/lib/gitlab/setup_helper.rb @@ -32,7 +32,10 @@ module Gitlab end if Rails.env.test? - storages << { name: 'test_second_storage', path: Rails.root.join('tmp', 'tests', 'second_storage').to_s } + storage_path = Rails.root.join('tmp', 'tests', 'second_storage').to_s + + FileUtils.mkdir(storage_path) unless File.exist?(storage_path) + storages << { name: 'test_second_storage', path: storage_path } end config = { socket_path: address.sub(/\Aunix:/, ''), storage: storages } diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 24d89de7b77..26270595c6a 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -4030,6 +4030,12 @@ msgstr "" msgid "No" msgstr "" +msgid "No Assignee" +msgstr "" + +msgid "No Label" +msgstr "" + msgid "No assignee" msgstr "" @@ -4135,6 +4141,12 @@ msgstr "" msgid "Notes|Are you sure you want to cancel creating this comment?" msgstr "" +msgid "Notes|Show all activity" +msgstr "" + +msgid "Notes|Show comments only" +msgstr "" + msgid "Notification events" msgstr "" @@ -5592,6 +5604,9 @@ msgstr "" msgid "Something went wrong while closing the %{issuable}. Please try again later" msgstr "" +msgid "Something went wrong while fetching comments. Please try again." +msgstr "" + msgid "Something went wrong while fetching the projects." msgstr "" @@ -6591,6 +6606,9 @@ msgstr "" msgid "Up to date" msgstr "" +msgid "Upcoming" +msgstr "" + msgid "Update" msgstr "" diff --git a/scripts/review_apps/review-apps.sh b/scripts/review_apps/review-apps.sh index 78293464265..d372bcbdab1 100755 --- a/scripts/review_apps/review-apps.sh +++ b/scripts/review_apps/review-apps.sh @@ -47,15 +47,23 @@ function create_secret() { --dry-run -o json | kubectl apply -f - } +function deployExists() { + local namespace="${1}" + local deploy="${2}" + helm status --tiller-namespace "${namespace}" "${deploy}" >/dev/null 2>&1 + return $? +} + function previousDeployFailed() { set +e - echo "Checking for previous deployment of $CI_ENVIRONMENT_SLUG" - deployment_status=$(helm status $CI_ENVIRONMENT_SLUG >/dev/null 2>&1) + deploy="${1}" + echo "Checking for previous deployment of ${deploy}" + deployment_status=$(helm status ${deploy} >/dev/null 2>&1) status=$? # if `status` is `0`, deployment exists, has a status if [ $status -eq 0 ]; then echo "Previous deployment found, checking status" - deployment_status=$(helm status $CI_ENVIRONMENT_SLUG | grep ^STATUS | cut -d' ' -f2) + deployment_status=$(helm status ${deploy} | grep ^STATUS | cut -d' ' -f2) echo "Previous deployment state: $deployment_status" if [[ "$deployment_status" == "FAILED" || "$deployment_status" == "PENDING_UPGRADE" || "$deployment_status" == "PENDING_INSTALL" ]]; then status=0; @@ -113,7 +121,7 @@ function deploy() { fi # Cleanup and previous installs, as FAILED and PENDING_UPGRADE will cause errors with `upgrade` - if [ "$CI_ENVIRONMENT_SLUG" != "production" ] && previousDeployFailed ; then + if [ "$CI_ENVIRONMENT_SLUG" != "production" ] && previousDeployFailed "$CI_ENVIRONMENT_SLUG" ; then echo "Deployment in bad state, cleaning up $CI_ENVIRONMENT_SLUG" delete cleanup @@ -149,6 +157,7 @@ HELM_CMD=$(cat << EOF --set gitlab.gitlab-shell.image.tag="v$GITLAB_SHELL_VERSION" \ --set gitlab.unicorn.workhorse.image="$gitlab_workhorse_image_repository" \ --set gitlab.unicorn.workhorse.tag="$CI_COMMIT_REF_NAME" \ + --set nginx-ingress.controller.config.ssl-ciphers="ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA:ECDHE-RSA-AES128-SHA:AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:AES256-SHA:AES128-SHA:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4" \ --namespace="$KUBE_NAMESPACE" \ --version="$CI_PIPELINE_ID-$CI_JOB_ID" \ "$name" \ @@ -182,3 +191,23 @@ function cleanup() { | xargs kubectl -n "$KUBE_NAMESPACE" delete \ || true } + +function install_external_dns() { + local release_name="dns-gitlab-review-app" + local domain=$(echo "${REVIEW_APPS_DOMAIN}" | awk -F. '{printf "%s.%s", $(NF-1), $NF}') + + if ! deployExists "${KUBE_NAMESPACE}" "${release_name}" || previousDeployFailed "${release_name}" ; then + echo "Installing external-dns helm chart" + helm repo update + helm install stable/external-dns \ + -n "${release_name}" \ + --namespace "${KUBE_NAMESPACE}" \ + --set provider="aws" \ + --set aws.secretKey="${REVIEW_APPS_AWS_SECRET_KEY}" \ + --set aws.accessKey="${REVIEW_APPS_AWS_ACCESS_KEY}" \ + --set aws.zoneType="public" \ + --set domainFilters[0]="${domain}" \ + --set txtOwnerId="${KUBE_NAMESPACE}" \ + --set rbac.create="true" + fi +} diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 9df77560320..80138183c07 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -1028,6 +1028,13 @@ describe Projects::IssuesController do .not_to exceed_query_limit(control) end + context 'when user is setting notes filters' do + let(:issuable) { issue } + let!(:discussion_note) { create(:discussion_note_on_issue, :system, noteable: issuable, project: project) } + + it_behaves_like 'issuable notes filter' + end + context 'with cross-reference system note', :request_store do let(:new_issue) { create(:issue) } let(:cross_reference) { "mentioned in #{new_issue.to_reference(issue.project)}" } diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index 78581dc37a4..dcfd6c05200 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -87,6 +87,14 @@ describe Projects::MergeRequestsController do end end + context 'when user is setting notes filters' do + let(:issuable) { merge_request } + let!(:discussion_note) { create(:discussion_note_on_merge_request, :system, noteable: issuable, project: project) } + let!(:discussion_comment) { create(:discussion_note_on_merge_request, noteable: issuable, project: project) } + + it_behaves_like 'issuable notes filter' + end + describe 'as json' do context 'with basic serializer param' do it 'renders basic MR entity as json' do diff --git a/spec/controllers/projects/notes_controller_spec.rb b/spec/controllers/projects/notes_controller_spec.rb index e48c9dea976..9ac7b8ee8a8 100644 --- a/spec/controllers/projects/notes_controller_spec.rb +++ b/spec/controllers/projects/notes_controller_spec.rb @@ -47,6 +47,37 @@ describe Projects::NotesController do get :index, request_params end + context 'when user notes_filter is present' do + let(:notes_json) { parsed_response[:notes] } + let!(:comment) { create(:note, noteable: issue, project: project) } + let!(:system_note) { create(:note, noteable: issue, project: project, system: true) } + + it 'filters system notes by comments' do + user.set_notes_filter(UserPreference::NOTES_FILTERS[:only_comments], issue) + + get :index, request_params + + expect(notes_json.count).to eq(1) + expect(notes_json.first[:id].to_i).to eq(comment.id) + end + + it 'returns all notes' do + user.set_notes_filter(UserPreference::NOTES_FILTERS[:all_notes], issue) + + get :index, request_params + + expect(notes_json.map { |note| note[:id].to_i }).to contain_exactly(comment.id, system_note.id) + end + + it 'does not merge label event notes' do + user.set_notes_filter(UserPreference::NOTES_FILTERS[:only_comments], issue) + + expect(ResourceEvents::MergeIntoNotesService).not_to receive(:new) + + get :index, request_params + end + end + context 'for a discussion note' do let(:project) { create(:project, :repository) } let!(:note) { create(:discussion_note_on_merge_request, project: project) } diff --git a/spec/factories/user_preferences.rb b/spec/factories/user_preferences.rb new file mode 100644 index 00000000000..19059a93625 --- /dev/null +++ b/spec/factories/user_preferences.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :user_preference do + user + + trait :only_comments do + issue_notes_filter { UserPreference::NOTES_FILTERS[:only_comments] } + merge_request_notes_filter { UserPreference::NOTE_FILTERS[:only_comments] } + end + end +end diff --git a/spec/features/issues/filtered_search/dropdown_milestone_spec.rb b/spec/features/issues/filtered_search/dropdown_milestone_spec.rb index f76d30056da..ef5801e61e8 100644 --- a/spec/features/issues/filtered_search/dropdown_milestone_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_milestone_spec.rb @@ -189,13 +189,21 @@ describe 'Dropdown milestone', :js do end it 'selects `no milestone`' do - click_static_milestone('No Milestone') + click_static_milestone('None') expect(page).to have_css(js_dropdown_milestone, visible: false) expect_tokens([milestone_token('none', false)]) expect_filtered_search_input_empty end + it 'selects `any milestone`' do + click_static_milestone('Any') + + expect(page).to have_css(js_dropdown_milestone, visible: false) + expect_tokens([milestone_token('any', false)]) + expect_filtered_search_input_empty + end + it 'selects `upcoming milestone`' do click_static_milestone('Upcoming') diff --git a/spec/features/projects/jobs_spec.rb b/spec/features/projects/jobs_spec.rb index 1ea8a640e17..c3902ecdd17 100644 --- a/spec/features/projects/jobs_spec.rb +++ b/spec/features/projects/jobs_spec.rb @@ -151,9 +151,8 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do end it 'renders escaped tooltip name' do - page.within('aside.right-sidebar') do - expect(find('.active.build-job a')['data-original-title']).to eq('<img src=x onerror=alert(document.domain)> - passed') - end + page.find('.active.build-job a').hover + expect(page).to have_content('<img src=x onerror=alert(document.domain)> - passed') end end diff --git a/spec/finders/notes_finder_spec.rb b/spec/finders/notes_finder_spec.rb index b776e9d856a..de9974c45e1 100644 --- a/spec/finders/notes_finder_spec.rb +++ b/spec/finders/notes_finder_spec.rb @@ -9,6 +9,27 @@ describe NotesFinder do end describe '#execute' do + context 'when notes filter is present' do + let!(:comment) { create(:note_on_issue, project: project) } + let!(:system_note) { create(:note_on_issue, project: project, system: true) } + + it 'filters system notes' do + finder = described_class.new(project, user, notes_filter: UserPreference::NOTES_FILTERS[:only_comments]) + + notes = finder.execute + + expect(notes).to match_array(comment) + end + + it 'gets all notes' do + finder = described_class.new(project, user, notes_filter: UserPreference::NOTES_FILTERS[:all_activity]) + + notes = finder.execute + + expect(notes).to match_array([comment, system_note]) + end + end + it 'finds notes on merge requests' do create(:note_on_merge_request, project: project) diff --git a/spec/javascripts/collapsed_sidebar_todo_spec.js b/spec/javascripts/collapsed_sidebar_todo_spec.js index bdee85f90b1..dc5737558c0 100644 --- a/spec/javascripts/collapsed_sidebar_todo_spec.js +++ b/spec/javascripts/collapsed_sidebar_todo_spec.js @@ -45,8 +45,10 @@ describe('Issuable right sidebar collapsed todo toggle', () => { expect(document.querySelector('.js-issuable-todo.sidebar-collapsed-icon')).not.toBeNull(); expect( - document.querySelector('.js-issuable-todo.sidebar-collapsed-icon .fa-plus-square'), - ).not.toBeNull(); + document + .querySelector('.js-issuable-todo.sidebar-collapsed-icon svg use') + .getAttribute('xlink:href'), + ).toContain('todo-add'); expect( document.querySelector('.js-issuable-todo.sidebar-collapsed-icon .todo-undone'), @@ -68,8 +70,10 @@ describe('Issuable right sidebar collapsed todo toggle', () => { ).not.toBeNull(); expect( - document.querySelector('.js-issuable-todo.sidebar-collapsed-icon .fa-check-square'), - ).not.toBeNull(); + document + .querySelector('.js-issuable-todo.sidebar-collapsed-icon svg.todo-undone use') + .getAttribute('xlink:href'), + ).toContain('todo-done'); done(); }); diff --git a/spec/javascripts/notes/components/discussion_filter_spec.js b/spec/javascripts/notes/components/discussion_filter_spec.js new file mode 100644 index 00000000000..70dd5bb3be5 --- /dev/null +++ b/spec/javascripts/notes/components/discussion_filter_spec.js @@ -0,0 +1,60 @@ +import Vue from 'vue'; +import createStore from '~/notes/stores'; +import DiscussionFilter from '~/notes/components/discussion_filter.vue'; +import { mountComponentWithStore } from '../../helpers/vue_mount_component_helper'; +import { discussionFiltersMock, discussionMock } from '../mock_data'; + +describe('DiscussionFilter component', () => { + let vm; + let store; + + beforeEach(() => { + store = createStore(); + + const discussions = [{ + ...discussionMock, + id: discussionMock.id, + notes: [{ ...discussionMock.notes[0], resolvable: true, resolved: true }], + }]; + const Component = Vue.extend(DiscussionFilter); + const defaultValue = discussionFiltersMock[0].value; + + store.state.discussions = discussions; + vm = mountComponentWithStore(Component, { + el: null, + store, + props: { + filters: discussionFiltersMock, + defaultValue, + }, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders the all filters', () => { + expect(vm.$el.querySelectorAll('.dropdown-menu li').length).toEqual(discussionFiltersMock.length); + }); + + it('renders the default selected item', () => { + expect(vm.$el.querySelector('#discussion-filter-dropdown').textContent.trim()).toEqual(discussionFiltersMock[0].title); + }); + + it('updates to the selected item', () => { + const filterItem = vm.$el.querySelector('.dropdown-menu li:last-child button'); + filterItem.click(); + + expect(vm.currentFilter.title).toEqual(filterItem.textContent.trim()); + }); + + it('only updates when selected filter changes', () => { + const filterItem = vm.$el.querySelector('.dropdown-menu li:first-child button'); + + spyOn(vm, 'filterDiscussion'); + filterItem.click(); + + expect(vm.filterDiscussion).not.toHaveBeenCalled(); + }); +}); diff --git a/spec/javascripts/notes/components/note_app_spec.js b/spec/javascripts/notes/components/note_app_spec.js index 3e289a6b8e6..06b30375306 100644 --- a/spec/javascripts/notes/components/note_app_spec.js +++ b/spec/javascripts/notes/components/note_app_spec.js @@ -97,8 +97,7 @@ describe('note_app', () => { }); it('should render list of notes', done => { - const note = - mockData.INDIVIDUAL_NOTE_RESPONSE_MAP.GET[ + const note = mockData.INDIVIDUAL_NOTE_RESPONSE_MAP.GET[ '/gitlab-org/gitlab-ce/issues/26/discussions.json' ][0].notes[0]; diff --git a/spec/javascripts/notes/mock_data.js b/spec/javascripts/notes/mock_data.js index 9a0e7f34a9c..ad0e793b915 100644 --- a/spec/javascripts/notes/mock_data.js +++ b/spec/javascripts/notes/mock_data.js @@ -1244,3 +1244,18 @@ export const discussion3 = { export const unresolvableDiscussion = { resolvable: false, }; + +export const discussionFiltersMock = [ + { + title: 'Show all activity', + value: 0, + }, + { + title: 'Show comments only', + value: 1, + }, + { + title: 'Show system notes only', + value: 2, + }, +]; diff --git a/spec/lib/gitaly/server_spec.rb b/spec/lib/gitaly/server_spec.rb index 09bf21b5946..292ab870dad 100644 --- a/spec/lib/gitaly/server_spec.rb +++ b/spec/lib/gitaly/server_spec.rb @@ -26,9 +26,7 @@ describe Gitaly::Server do end end - context 'when the storage is not readable' do - let(:server) { described_class.new('broken') } - + context 'when the storage is not readable', :broken_storage do it 'returns false' do expect(server).not_to be_readable end @@ -42,9 +40,7 @@ describe Gitaly::Server do end end - context 'when the storage is not writeable' do - let(:server) { described_class.new('broken') } - + context 'when the storage is not writeable', :broken_storage do it 'returns false' do expect(server).not_to be_writeable end diff --git a/spec/lib/gitlab/ci/config/entry/reports_spec.rb b/spec/lib/gitlab/ci/config/entry/reports_spec.rb index 7cf541447ce..8095a231cf3 100644 --- a/spec/lib/gitlab/ci/config/entry/reports_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/reports_spec.rb @@ -38,6 +38,8 @@ describe Gitlab::Ci::Config::Entry::Reports do :dependency_scanning | 'gl-dependency-scanning-report.json' :container_scanning | 'gl-container-scanning-report.json' :dast | 'gl-dast-report.json' + :license_management | 'gl-license-management-report.json' + :performance | 'performance.json' end with_them do diff --git a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb index 7ebfc61f5e7..b0570680d5a 100644 --- a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb @@ -335,7 +335,7 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do restored_project_json - expect(project.lfs_enabled).to be_nil + expect(project.lfs_enabled).to be_falsey end end diff --git a/spec/lib/gitlab/kubernetes/role_binding_spec.rb b/spec/lib/gitlab/kubernetes/role_binding_spec.rb new file mode 100644 index 00000000000..da3f5d27b25 --- /dev/null +++ b/spec/lib/gitlab/kubernetes/role_binding_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Kubernetes::RoleBinding, '#generate' do + let(:role_name) { 'edit' } + let(:namespace) { 'my-namespace' } + let(:service_account_name) { 'my-service-account' } + + let(:subjects) do + [ + { + kind: 'ServiceAccount', + name: service_account_name, + namespace: namespace + } + ] + end + + let(:role_ref) do + { + apiGroup: 'rbac.authorization.k8s.io', + kind: 'Role', + name: role_name + } + end + + let(:resource) do + ::Kubeclient::Resource.new( + metadata: { name: "gitlab-#{namespace}", namespace: namespace }, + roleRef: role_ref, + subjects: subjects + ) + end + + subject do + described_class.new( + role_name: role_name, + namespace: namespace, + service_account_name: service_account_name + ).generate + end + + it 'should build a Kubeclient Resource' do + is_expected.to eq(resource) + end +end diff --git a/spec/models/clusters/applications/runner_spec.rb b/spec/models/clusters/applications/runner_spec.rb index 23643d1c4d2..d5fb1a9d010 100644 --- a/spec/models/clusters/applications/runner_spec.rb +++ b/spec/models/clusters/applications/runner_spec.rb @@ -17,7 +17,7 @@ describe Clusters::Applications::Runner do let(:application) { create(:clusters_applications_runner, :scheduled, version: '0.1.30') } it 'updates the application version' do - expect(application.reload.version).to eq('0.1.34') + expect(application.reload.version).to eq('0.1.35') end end end @@ -45,7 +45,7 @@ describe Clusters::Applications::Runner do it 'should be initialized with 4 arguments' do expect(subject.name).to eq('runner') expect(subject.chart).to eq('runner/gitlab-runner') - expect(subject.version).to eq('0.1.34') + expect(subject.version).to eq('0.1.35') expect(subject).not_to be_rbac expect(subject.repository).to eq('https://charts.gitlab.io') expect(subject.files).to eq(gitlab_runner.files) @@ -63,7 +63,7 @@ describe Clusters::Applications::Runner do let(:gitlab_runner) { create(:clusters_applications_runner, :errored, runner: ci_runner, version: '0.1.13') } it 'should be initialized with the locked version' do - expect(subject.version).to eq('0.1.34') + expect(subject.version).to eq('0.1.35') end end end diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb index 1783dd3206b..f9be61e4768 100644 --- a/spec/models/note_spec.rb +++ b/spec/models/note_spec.rb @@ -865,5 +865,29 @@ describe Note do note.save! end end + + describe '#with_notes_filter' do + let!(:comment) { create(:note) } + let!(:system_note) { create(:note, system: true) } + + context 'when notes filter is nil' do + subject { described_class.with_notes_filter(nil) } + + it { is_expected.to include(comment, system_note) } + end + + context 'when notes filter is set to all notes' do + subject { described_class.with_notes_filter(UserPreference::NOTES_FILTERS[:all_notes]) } + + it { is_expected.to include(comment, system_note) } + end + + context 'when notes filter is set to only comments' do + subject { described_class.with_notes_filter(UserPreference::NOTES_FILTERS[:only_comments]) } + + it { is_expected.to include(comment) } + it { is_expected.not_to include(system_note) } + end + end end end diff --git a/spec/models/user_preference_spec.rb b/spec/models/user_preference_spec.rb new file mode 100644 index 00000000000..64d9d9a78b4 --- /dev/null +++ b/spec/models/user_preference_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe UserPreference do + describe '#set_notes_filter' do + let(:issuable) { build_stubbed(:issue) } + let(:user_preference) { create(:user_preference) } + let(:only_comments) { described_class::NOTES_FILTERS[:only_comments] } + + it 'returns updated discussion filter' do + filter_name = + user_preference.set_notes_filter(only_comments, issuable) + + expect(filter_name).to eq(only_comments) + end + + it 'updates discussion filter for issuable class' do + user_preference.set_notes_filter(only_comments, issuable) + + expect(user_preference.reload.issue_notes_filter).to eq(only_comments) + end + + context 'when notes_filter parameter is invalid' do + it 'returns the current notes filter' do + user_preference.set_notes_filter(only_comments, issuable) + + expect(user_preference.set_notes_filter(9999, issuable)).to eq(only_comments) + end + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 99d17f563d9..b3474e74aa4 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -715,6 +715,15 @@ describe User do end end + describe 'ensure user preference' do + it 'has user preference upon user initialization' do + user = build(:user) + + expect(user.user_preference).to be_present + expect(user.user_preference).not_to be_persisted + end + end + describe 'ensure incoming email token' do it 'has incoming email token' do user = create(:user) diff --git a/spec/services/ci/retry_build_service_spec.rb b/spec/services/ci/retry_build_service_spec.rb index 642de81ed52..368abded448 100644 --- a/spec/services/ci/retry_build_service_spec.rb +++ b/spec/services/ci/retry_build_service_spec.rb @@ -27,6 +27,7 @@ describe Ci::RetryBuildService do job_artifacts_metadata job_artifacts_trace job_artifacts_junit job_artifacts_sast job_artifacts_dependency_scanning job_artifacts_container_scanning job_artifacts_dast + job_artifacts_license_management job_artifacts_performance job_artifacts_codequality scheduled_at].freeze IGNORE_ACCESSORS = diff --git a/spec/support/helpers/test_env.rb b/spec/support/helpers/test_env.rb index 1a9aa252511..71d72ff27e9 100644 --- a/spec/support/helpers/test_env.rb +++ b/spec/support/helpers/test_env.rb @@ -70,7 +70,6 @@ module TestEnv TMP_TEST_PATH = Rails.root.join('tmp', 'tests', '**') REPOS_STORAGE = 'default'.freeze - BROKEN_STORAGE = 'broken'.freeze # Test environment # @@ -159,10 +158,6 @@ module TestEnv version: Gitlab::GitalyClient.expected_server_version, task: "gitlab:gitaly:install[#{gitaly_dir},#{repos_path}]") do - # Re-create config, to specify the broken storage path - storage_paths = { 'default' => repos_path, 'broken' => broken_path } - Gitlab::SetupHelper.create_gitaly_configuration(gitaly_dir, storage_paths, force: true) - start_gitaly(gitaly_dir) end end @@ -173,6 +168,8 @@ module TestEnv return end + FileUtils.mkdir_p("tmp/tests/second_storage") unless File.exist?("tmp/tests/second_storage") + spawn_script = Rails.root.join('scripts/gitaly-test-spawn').to_s Bundler.with_original_env do raise "gitaly spawn failed" unless system(spawn_script) @@ -257,10 +254,6 @@ module TestEnv @repos_path ||= Gitlab.config.repositories.storages[REPOS_STORAGE].legacy_disk_path end - def broken_path - @broken_path ||= Gitlab.config.repositories.storages[BROKEN_STORAGE].legacy_disk_path - end - def backup_path Gitlab.config.backup.path end diff --git a/spec/support/shared_examples/controllers/issuable_notes_filter_shared_examples.rb b/spec/support/shared_examples/controllers/issuable_notes_filter_shared_examples.rb new file mode 100644 index 00000000000..9c9d7ad781e --- /dev/null +++ b/spec/support/shared_examples/controllers/issuable_notes_filter_shared_examples.rb @@ -0,0 +1,54 @@ +shared_examples 'issuable notes filter' do + it 'sets discussion filter' do + notes_filter = UserPreference::NOTES_FILTERS[:only_comments] + + get :discussions, namespace_id: project.namespace, project_id: project, id: issuable.iid, notes_filter: notes_filter + + expect(user.reload.notes_filter_for(issuable)).to eq(notes_filter) + expect(UserPreference.count).to eq(1) + end + + it 'expires notes e-tag cache for issuable if filter changed' do + notes_filter = UserPreference::NOTES_FILTERS[:only_comments] + + expect_any_instance_of(issuable.class).to receive(:expire_note_etag_cache) + + get :discussions, namespace_id: project.namespace, project_id: project, id: issuable.iid, notes_filter: notes_filter + end + + it 'does not expires notes e-tag cache for issuable if filter did not change' do + notes_filter = UserPreference::NOTES_FILTERS[:only_comments] + user.set_notes_filter(notes_filter, issuable) + + expect_any_instance_of(issuable.class).not_to receive(:expire_note_etag_cache) + + get :discussions, namespace_id: project.namespace, project_id: project, id: issuable.iid, notes_filter: notes_filter + end + + it 'does not set notes filter when database is in read only mode' do + allow(Gitlab::Database).to receive(:read_only?).and_return(true) + notes_filter = UserPreference::NOTES_FILTERS[:only_comments] + + get :discussions, namespace_id: project.namespace, project_id: project, id: issuable.iid, notes_filter: notes_filter + + expect(user.reload.notes_filter_for(issuable)).to eq(0) + end + + it 'returns no system note' do + user.set_notes_filter(UserPreference::NOTES_FILTERS[:only_comments], issuable) + + get :discussions, namespace_id: project.namespace, project_id: project, id: issuable.iid + + expect(JSON.parse(response.body).count).to eq(1) + end + + context 'when filter is set to "only_comments"' do + it 'does not merge label event notes' do + user.set_notes_filter(UserPreference::NOTES_FILTERS[:only_comments], issuable) + + expect(ResourceEvents::MergeIntoNotesService).not_to receive(:new) + + get :discussions, namespace_id: project.namespace, project_id: project, id: issuable.iid + end + end +end diff --git a/spec/support/stored_repositories.rb b/spec/support/stored_repositories.rb index 6a9ad43941d..55212355daa 100644 --- a/spec/support/stored_repositories.rb +++ b/spec/support/stored_repositories.rb @@ -1,8 +1,4 @@ RSpec.configure do |config| - config.before(:all, :broken_storage) do - FileUtils.rm_rf Gitlab.config.repositories.storages.broken.legacy_disk_path - end - config.before(:each, :broken_storage) do allow(Gitlab::GitalyClient).to receive(:call) do raise GRPC::Unavailable.new('Gitaly broken in this spec') diff --git a/spec/workers/repository_check/batch_worker_spec.rb b/spec/workers/repository_check/batch_worker_spec.rb index ede271b2cdd..50b93fce2dc 100644 --- a/spec/workers/repository_check/batch_worker_spec.rb +++ b/spec/workers/repository_check/batch_worker_spec.rb @@ -51,7 +51,7 @@ describe RepositoryCheck::BatchWorker do it 'does nothing when shard is unhealthy' do shard_name = 'broken' - create(:project, created_at: 1.week.ago, repository_storage: shard_name) + create(:project, :broken_storage, created_at: 1.week.ago) expect(subject.perform(shard_name)).to eq(nil) end |