diff options
189 files changed, 2694 insertions, 992 deletions
diff --git a/.gitlab/issue_templates/Add style proposal b/.gitlab/issue_templates/Add style proposal.md index 1a3be44bea0..1a3be44bea0 100644 --- a/.gitlab/issue_templates/Add style proposal +++ b/.gitlab/issue_templates/Add style proposal.md diff --git a/.gitlab/issue_templates/Security developer workflow.md b/.gitlab/issue_templates/Security developer workflow.md index 08651195d98..f9bf700f809 100644 --- a/.gitlab/issue_templates/Security developer workflow.md +++ b/.gitlab/issue_templates/Security developer workflow.md @@ -20,7 +20,7 @@ Set the title to: `[Security] Description of the original issue` #### Backports -- [ ] Once the MR is ready to be merged, create MRs targetting the last 3 releases +- [ ] Once the MR is ready to be merged, create MRs targetting the last 3 releases, plus the current RC if between the 7th and 22nd of the month. - [ ] At this point, it might be easy to squash the commits from the MR into one - You can use the script `bin/secpick` instead of the following steps, to help you cherry-picking. See the [secpick documentation] - [ ] 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) @@ -112,7 +112,7 @@ gem 'seed-fu', '~> 2.3.7' # Markdown and HTML processing gem 'html-pipeline', '~> 2.8' -gem 'deckar01-task_list', '2.0.0' +gem 'deckar01-task_list', '2.0.1' gem 'gitlab-markup', '~> 1.6.5' gem 'github-markup', '~> 1.7.0', require: 'github/markup' gem 'redcarpet', '~> 3.4' diff --git a/Gemfile.lock b/Gemfile.lock index 075aa616215..5098c6fb88e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -143,7 +143,7 @@ GEM database_cleaner (1.7.0) debug_inspector (0.0.3) debugger-ruby_core_source (1.3.8) - deckar01-task_list (2.0.0) + deckar01-task_list (2.0.1) html-pipeline declarative (0.0.10) declarative-option (0.1.0) @@ -282,7 +282,7 @@ GEM gitlab-markup (1.6.5) gitlab-sidekiq-fetcher (0.4.0) sidekiq (~> 5) - gitlab-styles (2.4.1) + gitlab-styles (2.5.1) rubocop (~> 0.54.0) rubocop-gitlab-security (~> 0.1.0) rubocop-rspec (~> 1.19) @@ -974,7 +974,7 @@ DEPENDENCIES connection_pool (~> 2.0) creole (~> 0.5.0) database_cleaner (~> 1.7.0) - deckar01-task_list (= 2.0.0) + deckar01-task_list (= 2.0.1) device_detector devise (~> 4.4) devise-two-factor (~> 3.0.0) diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue index f01e6f2a639..6ffb8c4e1c0 100644 --- a/app/assets/javascripts/deploy_keys/components/key.vue +++ b/app/assets/javascripts/deploy_keys/components/key.vue @@ -113,7 +113,7 @@ export default { <div class="gl-responsive-table-row deploy-key"> <div class="table-section section-40"> <div role="rowheader" class="table-mobile-header">{{ s__('DeployKeys|Deploy key') }}</div> - <div class="table-mobile-content"> + <div class="table-mobile-content qa-key"> <strong class="title qa-key-title"> {{ deployKey.title }} </strong> <div class="fingerprint qa-key-fingerprint">{{ deployKey.fingerprint }}</div> </div> diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue new file mode 100644 index 00000000000..6981afe1ead --- /dev/null +++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue @@ -0,0 +1,118 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import { GlEmptyState, GlButton, GlLink, GlLoadingIcon, GlTable } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import { __ } from '~/locale'; + +export default { + fields: [ + { key: 'error', label: __('Open errors') }, + { key: 'events', label: __('Events') }, + { key: 'users', label: __('Users') }, + { key: 'lastSeen', label: __('Last seen') }, + ], + components: { + GlEmptyState, + GlButton, + GlLink, + GlLoadingIcon, + GlTable, + Icon, + TimeAgo, + }, + props: { + indexPath: { + type: String, + required: true, + }, + enableErrorTrackingLink: { + type: String, + required: true, + }, + errorTrackingEnabled: { + type: Boolean, + required: true, + }, + illustrationPath: { + type: String, + required: true, + }, + }, + computed: { + ...mapState(['errors', 'externalUrl', 'loading']), + }, + created() { + if (this.errorTrackingEnabled) { + this.startPolling(this.indexPath); + } + }, + methods: { + ...mapActions(['startPolling']), + }, +}; +</script> + +<template> + <div> + <div v-if="errorTrackingEnabled"> + <div v-if="loading" class="py-3"><gl-loading-icon :size="3" /></div> + <div v-else> + <div class="d-flex justify-content-end"> + <gl-button class="my-3 ml-auto" variant="primary" :href="externalUrl" target="_blank" + >View in Sentry <icon name="external-link" /> + </gl-button> + </div> + <gl-table + :items="errors" + :fields="$options.fields" + :show-empty="true" + :empty-text="__('No errors to display')" + > + <template slot="HEAD_events" slot-scope="data"> + <div class="text-right">{{ data.label }}</div> + </template> + <template slot="HEAD_users" slot-scope="data"> + <div class="text-right">{{ data.label }}</div> + </template> + <template slot="error" slot-scope="errors"> + <div class="d-flex flex-column"> + <div class="d-flex"> + <gl-link :href="errors.item.externalUrl" class="d-flex text-dark" target="_blank"> + <strong>{{ errors.item.title.trim() }}</strong> + <icon name="external-link" class="ml-1" /> + </gl-link> + <span class="text-secondary ml-2">{{ errors.item.culprit }}</span> + </div> + {{ errors.item.message || __('No details available') }} + </div> + </template> + + <template slot="events" slot-scope="errors"> + <div class="text-right">{{ errors.item.count }}</div> + </template> + + <template slot="users" slot-scope="errors"> + <div class="text-right">{{ errors.item.userCount }}</div> + </template> + + <template slot="lastSeen" slot-scope="errors"> + <div class="d-flex align-items-center"> + <icon name="calendar" css-classes="text-secondary mr-1" /> + <time-ago :time="errors.item.lastSeen" class="text-secondary" /> + </div> + </template> + </gl-table> + </div> + </div> + <div v-else> + <gl-empty-state + :title="__('Get started with error tracking')" + :description="__('Monitor your errors by integrating with Sentry')" + :primary-button-text="__('Enable error tracking')" + :primary-button-link="enableErrorTrackingLink" + :svg-path="illustrationPath" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/error_tracking/index.js b/app/assets/javascripts/error_tracking/index.js new file mode 100644 index 00000000000..808ae2c9a41 --- /dev/null +++ b/app/assets/javascripts/error_tracking/index.js @@ -0,0 +1,35 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import store from './store'; +import ErrorTrackingList from './components/error_tracking_list.vue'; + +export default () => { + if (!gon.features.errorTracking) { + return; + } + + // eslint-disable-next-line no-new + new Vue({ + el: '#js-error_tracking', + components: { + ErrorTrackingList, + }, + store, + render(createElement) { + const domEl = document.querySelector(this.$options.el); + const { indexPath, enableErrorTrackingLink, illustrationPath } = domEl.dataset; + let { errorTrackingEnabled } = domEl.dataset; + + errorTrackingEnabled = parseBoolean(errorTrackingEnabled); + + return createElement('error-tracking-list', { + props: { + indexPath, + enableErrorTrackingLink, + errorTrackingEnabled, + illustrationPath, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/error_tracking/services/index.js b/app/assets/javascripts/error_tracking/services/index.js new file mode 100644 index 00000000000..ab89521dc46 --- /dev/null +++ b/app/assets/javascripts/error_tracking/services/index.js @@ -0,0 +1,7 @@ +import axios from '~/lib/utils/axios_utils'; + +export default { + getErrorList({ endpoint }) { + return axios.get(endpoint); + }, +}; diff --git a/app/assets/javascripts/error_tracking/store/actions.js b/app/assets/javascripts/error_tracking/store/actions.js new file mode 100644 index 00000000000..2e192c958ba --- /dev/null +++ b/app/assets/javascripts/error_tracking/store/actions.js @@ -0,0 +1,31 @@ +import Service from '../services'; +import * as types from './mutation_types'; +import createFlash from '~/flash'; +import Poll from '~/lib/utils/poll'; +import { __ } from '~/locale'; + +let eTagPoll; + +export function startPolling({ commit }, endpoint) { + eTagPoll = new Poll({ + resource: Service, + method: 'getErrorList', + data: { endpoint }, + successCallback: ({ data }) => { + if (!data) { + return; + } + commit(types.SET_ERRORS, data.errors); + commit(types.SET_EXTERNAL_URL, data.external_url); + commit(types.SET_LOADING, false); + }, + errorCallback: () => { + commit(types.SET_LOADING, false); + createFlash(__('Failed to load errors from Sentry')); + }, + }); + + eTagPoll.makeRequest(); +} + +export default () => {}; diff --git a/app/assets/javascripts/error_tracking/store/index.js b/app/assets/javascripts/error_tracking/store/index.js new file mode 100644 index 00000000000..3136682fb64 --- /dev/null +++ b/app/assets/javascripts/error_tracking/store/index.js @@ -0,0 +1,19 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import mutations from './mutations'; + +Vue.use(Vuex); + +export const createStore = () => + new Vuex.Store({ + state: { + errors: [], + externalUrl: '', + loading: true, + }, + actions, + mutations, + }); + +export default createStore(); diff --git a/app/assets/javascripts/error_tracking/store/mutation_types.js b/app/assets/javascripts/error_tracking/store/mutation_types.js new file mode 100644 index 00000000000..f9d77a6b08e --- /dev/null +++ b/app/assets/javascripts/error_tracking/store/mutation_types.js @@ -0,0 +1,3 @@ +export const SET_ERRORS = 'SET_ERRORS'; +export const SET_EXTERNAL_URL = 'SET_EXTERNAL_URL'; +export const SET_LOADING = 'SET_LOADING'; diff --git a/app/assets/javascripts/error_tracking/store/mutations.js b/app/assets/javascripts/error_tracking/store/mutations.js new file mode 100644 index 00000000000..e4bd81db9c9 --- /dev/null +++ b/app/assets/javascripts/error_tracking/store/mutations.js @@ -0,0 +1,14 @@ +import * as types from './mutation_types'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; + +export default { + [types.SET_ERRORS](state, data) { + state.errors = convertObjectPropsToCamelCase(data, { deep: true }); + }, + [types.SET_EXTERNAL_URL](state, url) { + state.externalUrl = url; + }, + [types.SET_LOADING](state, loading) { + state.loading = loading; + }, +}; diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue index d2b7ce18290..d473d6a482d 100644 --- a/app/assets/javascripts/jobs/components/job_app.vue +++ b/app/assets/javascripts/jobs/components/job_app.vue @@ -80,7 +80,6 @@ export default { 'hasError', ]), ...mapGetters([ - 'headerActions', 'headerTime', 'shouldRenderCalloutMessage', 'shouldRenderTriggeredLabel', @@ -202,7 +201,6 @@ export default { :item-id="job.id" :time="headerTime" :user="job.user" - :actions="headerActions" :has-sidebar-button="true" :should-render-triggered-label="shouldRenderTriggeredLabel" :item-name="__('Job')" diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue index ad3e7dabc79..a2141dc3760 100644 --- a/app/assets/javascripts/jobs/components/sidebar.vue +++ b/app/assets/javascripts/jobs/components/sidebar.vue @@ -48,8 +48,7 @@ export default { return `${this.job.runner.description} (#${this.job.runner.id})`; }, retryButtonClass() { - let className = - 'js-retry-button float-right btn btn-retry d-none d-md-block d-lg-block d-xl-block'; + let className = 'js-retry-button btn btn-retry'; className += this.job.status && this.job.recoverable ? ' btn-primary' : ' btn-inverted-secondary'; return className; @@ -110,24 +109,27 @@ export default { <aside class="right-sidebar build-sidebar" data-offset-top="101" data-spy="affix"> <div class="sidebar-container"> <div class="blocks-container"> - <div class="block d-flex align-items-center"> - <h4 class="flex-grow-1 prepend-top-8 m-0">{{ job.name }}</h4> - <gl-link - v-if="job.retry_path" - :class="retryButtonClass" - :href="job.retry_path" - data-method="post" - rel="nofollow" - >{{ __('Retry') }}</gl-link - > - <gl-link - v-if="job.terminal_path" - :href="job.terminal_path" - class="js-terminal-link pull-right btn btn-primary btn-inverted visible-md-block visible-lg-block" - target="_blank" - > - {{ __('Debug') }} <icon name="external-link" /> - </gl-link> + <div class="block d-flex flex-nowrap align-items-center"> + <h4 class="my-0 mr-2">{{ job.name }}</h4> + <div class="flex-grow-1 flex-shrink-0 text-right"> + <gl-link + v-if="job.retry_path" + :class="retryButtonClass" + :href="job.retry_path" + data-method="post" + rel="nofollow" + >{{ __('Retry') }}</gl-link + > + <gl-link + v-if="job.cancel_path" + :href="job.cancel_path" + class="js-cancel-job btn btn-default" + data-method="post" + rel="nofollow" + >{{ __('Cancel') }}</gl-link + > + </div> + <gl-button :aria-label="__('Toggle Sidebar')" type="button" @@ -137,22 +139,24 @@ export default { <i aria-hidden="true" data-hidden="true" class="fa fa-angle-double-right"></i> </gl-button> </div> - <div v-if="job.retry_path || job.new_issue_path" class="block retry-link"> + + <div v-if="job.terminal_path || job.new_issue_path" class="block retry-link"> <gl-link v-if="job.new_issue_path" :href="job.new_issue_path" - class="js-new-issue btn btn-success btn-inverted" + class="js-new-issue btn btn-success btn-inverted float-left mr-2" >{{ __('New issue') }}</gl-link > <gl-link - v-if="job.retry_path" - :href="job.retry_path" - class="js-retry-job btn btn-inverted-secondary" - data-method="post" - rel="nofollow" - >{{ __('Retry') }}</gl-link + v-if="job.terminal_path" + :href="job.terminal_path" + class="js-terminal-link btn btn-primary btn-inverted visible-md-block visible-lg-block float-left" + target="_blank" > + {{ __('Debug') }} <icon name="external-link" :size="14" /> + </gl-link> </div> + <div :class="{ block: renderBlock }"> <detail-row v-if="job.duration" @@ -193,16 +197,6 @@ export default { tag }}</span> </p> - - <div v-if="job.cancel_path" class="btn-group prepend-top-5" role="group"> - <gl-link - :href="job.cancel_path" - class="js-cancel-job btn btn-sm btn-default" - data-method="post" - rel="nofollow" - >{{ __('Cancel') }}</gl-link - > - </div> </div> <artifacts-block v-if="hasArtifact" :artifact="job.artifact" /> diff --git a/app/assets/javascripts/jobs/store/getters.js b/app/assets/javascripts/jobs/store/getters.js index 35e92b0b5d9..98911717381 100644 --- a/app/assets/javascripts/jobs/store/getters.js +++ b/app/assets/javascripts/jobs/store/getters.js @@ -1,22 +1,6 @@ import _ from 'underscore'; -import { __ } from '~/locale'; import { isScrolledToBottom } from '~/lib/utils/scroll_utils'; -export const headerActions = state => { - if (state.job.new_issue_path) { - return [ - { - label: __('New issue'), - path: state.job.new_issue_path, - cssClass: - 'js-new-issue btn btn-success btn-inverted d-none d-md-block d-lg-block d-xl-block', - type: 'link', - }, - ]; - } - return []; -}; - export const headerTime = state => (state.job.started ? state.job.started : state.job.created_at); export const shouldRenderCalloutMessage = state => diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index 9850f7ce782..4ba84589705 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -42,22 +42,35 @@ export function mergeUrlParams(params, url) { return `${urlparts[1]}?${query}${urlparts[3]}`; } -export function removeParamQueryString(url, param) { - const decodedUrl = decodeURIComponent(url); - const urlVariables = decodedUrl.split('&'); - - return urlVariables.filter(variable => variable.indexOf(param) === -1).join('&'); -} - -export function removeParams(params, source = window.location.href) { - const url = document.createElement('a'); - url.href = source; +/** + * Removes specified query params from the url by returning a new url string that no longer + * includes the param/value pair. If no url is provided, `window.location.href` is used as + * the default value. + * + * @param {string[]} params - the query param names to remove + * @param {string} [url=windowLocation().href] - url from which the query param will be removed + * @returns {string} A copy of the original url but without the query param + */ +export function removeParams(params, url = window.location.href) { + const [rootAndQuery, fragment] = url.split('#'); + const [root, query] = rootAndQuery.split('?'); + + if (!query) { + return url; + } - params.forEach(param => { - url.search = removeParamQueryString(url.search, param); - }); + const encodedParams = params.map(param => encodeURIComponent(param)); + const updatedQuery = query + .split('&') + .filter(paramPair => { + const [foundParam] = paramPair.split('='); + return encodedParams.indexOf(foundParam) < 0; + }) + .join('&'); - return url.href; + const writableQuery = updatedQuery.length > 0 ? `?${updatedQuery}` : ''; + const writableFragment = fragment ? `#${fragment}` : ''; + return `${root}${writableQuery}${writableFragment}`; } export function getLocationHash(url = window.location.href) { @@ -66,6 +79,20 @@ export function getLocationHash(url = window.location.href) { return hashIndex === -1 ? null : url.substring(hashIndex + 1); } +/** + * Apply the fragment to the given url by returning a new url string that includes + * the fragment. If the given url already contains a fragment, the original fragment + * will be removed. + * + * @param {string} url - url to which the fragment will be applied + * @param {string} fragment - fragment to append + */ +export const setUrlFragment = (url, fragment) => { + const [rootUrl] = url.split('#'); + const encodedFragment = encodeURIComponent(fragment.replace(/^#/, '')); + return `${rootUrl}#${encodedFragment}`; +}; + export function visitUrl(url, external = false) { if (external) { // Simulate `target="blank" rel="noopener noreferrer"` diff --git a/app/assets/javascripts/notebook/cells/code.vue b/app/assets/javascripts/notebook/cells/code.vue index bd6736152f5..eefc801ed7a 100644 --- a/app/assets/javascripts/notebook/cells/code.vue +++ b/app/assets/javascripts/notebook/cells/code.vue @@ -1,11 +1,12 @@ <script> -import CodeCell from './code/index.vue'; +import CodeOutput from './code/index.vue'; import OutputCell from './output/index.vue'; export default { + name: 'CodeCell', components: { - 'code-cell': CodeCell, - 'output-cell': OutputCell, + CodeOutput, + OutputCell, }, props: { cell: { @@ -29,8 +30,8 @@ export default { hasOutput() { return this.cell.outputs.length; }, - output() { - return this.cell.outputs[0]; + outputs() { + return this.cell.outputs; }, }, }; @@ -38,7 +39,7 @@ export default { <template> <div class="cell"> - <code-cell + <code-output :raw-code="rawInputCode" :count="cell.execution_count" :code-css-class="codeCssClass" @@ -47,7 +48,7 @@ export default { <output-cell v-if="hasOutput" :count="cell.execution_count" - :output="output" + :outputs="outputs" :code-css-class="codeCssClass" /> </div> diff --git a/app/assets/javascripts/notebook/cells/code/index.vue b/app/assets/javascripts/notebook/cells/code/index.vue index 8bf2431c4c6..98b6cdd0944 100644 --- a/app/assets/javascripts/notebook/cells/code/index.vue +++ b/app/assets/javascripts/notebook/cells/code/index.vue @@ -3,8 +3,9 @@ import Prism from '../../lib/highlight'; import Prompt from '../prompt.vue'; export default { + name: 'CodeOutput', components: { - prompt: Prompt, + Prompt, }, props: { count: { diff --git a/app/assets/javascripts/notebook/cells/output/html.vue b/app/assets/javascripts/notebook/cells/output/html.vue index c6fc786fa76..8dc2d73af9b 100644 --- a/app/assets/javascripts/notebook/cells/output/html.vue +++ b/app/assets/javascripts/notebook/cells/output/html.vue @@ -4,13 +4,21 @@ import Prompt from '../prompt.vue'; export default { components: { - prompt: Prompt, + Prompt, }, props: { + count: { + type: Number, + required: true, + }, rawCode: { type: String, required: true, }, + index: { + type: Number, + required: true, + }, }, computed: { sanitizedOutput() { @@ -21,13 +29,16 @@ export default { }, }); }, + showOutput() { + return this.index === 0; + }, }, }; </script> <template> <div class="output"> - <prompt /> + <prompt type="Out" :count="count" :show-output="showOutput" /> <div v-html="sanitizedOutput"></div> </div> </template> diff --git a/app/assets/javascripts/notebook/cells/output/image.vue b/app/assets/javascripts/notebook/cells/output/image.vue index fe8c81398fb..f1130275525 100644 --- a/app/assets/javascripts/notebook/cells/output/image.vue +++ b/app/assets/javascripts/notebook/cells/output/image.vue @@ -6,6 +6,10 @@ export default { prompt: Prompt, }, props: { + count: { + type: Number, + required: true, + }, outputType: { type: String, required: true, @@ -14,10 +18,24 @@ export default { type: String, required: true, }, + index: { + type: Number, + required: true, + }, + }, + computed: { + imgSrc() { + return `data:${this.outputType};base64,${this.rawCode}`; + }, + showOutput() { + return this.index === 0; + }, }, }; </script> <template> - <div class="output"><prompt /> <img :src="'data:' + outputType + ';base64,' + rawCode" /></div> + <div class="output"> + <prompt type="out" :count="count" :show-output="showOutput" /> <img :src="imgSrc" /> + </div> </template> diff --git a/app/assets/javascripts/notebook/cells/output/index.vue b/app/assets/javascripts/notebook/cells/output/index.vue index bd0bcc0d819..c5ae7e7ee10 100644 --- a/app/assets/javascripts/notebook/cells/output/index.vue +++ b/app/assets/javascripts/notebook/cells/output/index.vue @@ -1,14 +1,9 @@ <script> -import CodeCell from '../code/index.vue'; -import Html from './html.vue'; -import Image from './image.vue'; +import CodeOutput from '../code/index.vue'; +import HtmlOutput from './html.vue'; +import ImageOutput from './image.vue'; export default { - components: { - 'code-cell': CodeCell, - 'html-output': Html, - 'image-output': Image, - }, props: { codeCssClass: { type: String, @@ -20,68 +15,69 @@ export default { required: false, default: 0, }, - output: { - type: Object, + outputs: { + type: Array, required: true, - default: () => ({}), }, }, - computed: { - componentName() { - if (this.output.text) { - return 'code-cell'; - } else if (this.output.data['image/png']) { - return 'image-output'; - } else if (this.output.data['text/html']) { - return 'html-output'; - } else if (this.output.data['image/svg+xml']) { - return 'html-output'; - } + data() { + return { + outputType: '', + }; + }, + methods: { + dataForType(output, type) { + let data = output.data[type]; - return 'code-cell'; - }, - rawCode() { - if (this.output.text) { - return this.output.text.join(''); + if (typeof data === 'object') { + data = data.join(''); } - return this.dataForType(this.outputType); + return data; }, - outputType() { - if (this.output.text) { - return ''; - } else if (this.output.data['image/png']) { - return 'image/png'; - } else if (this.output.data['text/html']) { - return 'text/html'; - } else if (this.output.data['image/svg+xml']) { - return 'image/svg+xml'; + getComponent(output) { + if (output.text) { + return CodeOutput; + } else if (output.data['image/png']) { + this.outputType = 'image/png'; + + return ImageOutput; + } else if (output.data['text/html']) { + this.outputType = 'text/html'; + + return HtmlOutput; + } else if (output.data['image/svg+xml']) { + this.outputType = 'image/svg+xml'; + + return HtmlOutput; } - return 'text/plain'; + this.outputType = 'text/plain'; + return CodeOutput; }, - }, - methods: { - dataForType(type) { - let data = this.output.data[type]; - - if (typeof data === 'object') { - data = data.join(''); + rawCode(output) { + if (output.text) { + return output.text.join(''); } - return data; + return this.dataForType(output, this.outputType); }, }, }; </script> <template> - <component - :is="componentName" - :output-type="outputType" - :count="count" - :raw-code="rawCode" - :code-css-class="codeCssClass" - type="output" - /> + <div> + <component + :is="getComponent(output)" + v-for="(output, index) in outputs" + :key="index" + type="output" + :output-type="outputType" + :count="count" + :index="index" + :raw-code="rawCode(output)" + :code-css-class="codeCssClass" + /> + </div> </template> diff --git a/app/assets/javascripts/notebook/cells/prompt.vue b/app/assets/javascripts/notebook/cells/prompt.vue index 3f1f239a806..1eeb61844a4 100644 --- a/app/assets/javascripts/notebook/cells/prompt.vue +++ b/app/assets/javascripts/notebook/cells/prompt.vue @@ -11,18 +11,26 @@ export default { required: false, default: 0, }, + showOutput: { + type: Boolean, + required: false, + default: true, + }, }, computed: { hasKeys() { return this.type !== '' && this.count; }, + showTypeText() { + return this.type && this.count && this.showOutput; + }, }, }; </script> <template> <div class="prompt"> - <span v-if="hasKeys"> {{ type }} [{{ count }}]: </span> + <span v-if="showTypeText"> {{ type }} [{{ count }}]: </span> </div> </template> diff --git a/app/assets/javascripts/notebook/index.vue b/app/assets/javascripts/notebook/index.vue index 6a54d0b3823..e7056c03e4a 100644 --- a/app/assets/javascripts/notebook/index.vue +++ b/app/assets/javascripts/notebook/index.vue @@ -3,8 +3,8 @@ import { MarkdownCell, CodeCell } from './cells'; export default { components: { - 'code-cell': CodeCell, - 'markdown-cell': MarkdownCell, + CodeCell, + MarkdownCell, }, props: { notebook: { diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 8bf02327cd2..7c17147dd01 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -370,7 +370,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" > <button :disabled="isSubmitButtonDisabled" - class="btn btn-create comment-btn js-comment-button js-comment-submit-button + class="btn btn-success js-comment-button js-comment-submit-button qa-comment-button" type="submit" @click.prevent="handleSave();" @@ -381,7 +381,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" :disabled="isSubmitButtonDisabled" name="button" type="button" - class="btn comment-btn note-type-toggle js-note-new-discussion dropdown-toggle qa-note-dropdown" + class="btn btn-success note-type-toggle js-note-new-discussion dropdown-toggle qa-note-dropdown" data-display="static" data-toggle="dropdown" aria-label="Open comment type dropdown" diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue index f5c410211b6..2d7c04ea614 100644 --- a/app/assets/javascripts/notes/components/discussion_filter.vue +++ b/app/assets/javascripts/notes/components/discussion_filter.vue @@ -1,6 +1,7 @@ <script> import $ from 'jquery'; import { mapGetters, mapActions } from 'vuex'; +import { getLocationHash } from '../../lib/utils/url_utility'; import Icon from '~/vue_shared/components/icon.vue'; import { DISCUSSION_FILTERS_DEFAULT_VALUE, @@ -44,29 +45,47 @@ export default { eventHub.$on('MergeRequestTabChange', this.toggleFilters); this.toggleFilters(currentTab); } + + window.addEventListener('hashchange', this.handleLocationHash); + this.handleLocationHash(); }, mounted() { this.toggleCommentsForm(); }, + destroyed() { + window.removeEventListener('hashchange', this.handleLocationHash); + }, methods: { - ...mapActions(['filterDiscussion', 'setCommentsDisabled']), + ...mapActions(['filterDiscussion', 'setCommentsDisabled', 'setTargetNoteHash']), selectFilter(value) { const filter = parseInt(value, 10); // close dropdown - $(this.$refs.dropdownToggle).dropdown('toggle'); + this.toggleDropdown(); if (filter === this.currentValue) return; this.currentValue = filter; this.filterDiscussion({ path: this.getNotesDataByProp('discussionsPath'), filter }); this.toggleCommentsForm(); }, + toggleDropdown() { + $(this.$refs.dropdownToggle).dropdown('toggle'); + }, toggleCommentsForm() { this.setCommentsDisabled(this.currentValue === HISTORY_ONLY_FILTER_VALUE); }, toggleFilters(tab) { this.displayFilters = tab === DISCUSSION_TAB_LABEL; }, + handleLocationHash() { + const hash = getLocationHash(); + + if (/^note_/.test(hash) && this.currentValue !== DISCUSSION_FILTERS_DEFAULT_VALUE) { + this.selectFilter(this.defaultValue); + this.toggleDropdown(); // close dropdown + this.setTargetNoteHash(hash); + } + }, }, }; </script> diff --git a/app/assets/javascripts/pages/projects/error_tracking/index.js b/app/assets/javascripts/pages/projects/error_tracking/index.js new file mode 100644 index 00000000000..5a8fe137e9a --- /dev/null +++ b/app/assets/javascripts/pages/projects/error_tracking/index.js @@ -0,0 +1,5 @@ +import ErrorTracking from '~/error_tracking'; + +document.addEventListener('DOMContentLoaded', () => { + ErrorTracking(); +}); diff --git a/app/assets/javascripts/pages/sessions/new/index.js b/app/assets/javascripts/pages/sessions/new/index.js index 07f32210d93..d54bff88f70 100644 --- a/app/assets/javascripts/pages/sessions/new/index.js +++ b/app/assets/javascripts/pages/sessions/new/index.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import UsernameValidator from './username_validator'; import SigninTabsMemoizer from './signin_tabs_memoizer'; import OAuthRememberMe from './oauth_remember_me'; +import preserveUrlFragment from './preserve_url_fragment'; document.addEventListener('DOMContentLoaded', () => { new UsernameValidator(); // eslint-disable-line no-new @@ -10,4 +11,8 @@ document.addEventListener('DOMContentLoaded', () => { new OAuthRememberMe({ container: $('.omniauth-container'), }).bindEvents(); + + // Save the URL fragment from the current window location. This will be present if the user was + // redirected to sign-in after attempting to access a protected URL that included a fragment. + preserveUrlFragment(window.location.hash); }); diff --git a/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js index 761618109a4..191221a48cd 100644 --- a/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js +++ b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { mergeUrlParams, removeParams } from '~/lib/utils/url_utility'; /** * OAuth-based login buttons have a separate "remember me" checkbox. @@ -24,9 +25,9 @@ export default class OAuthRememberMe { const href = $(element).attr('href'); if (rememberMe) { - $(element).attr('href', `${href}?remember_me=1`); + $(element).attr('href', mergeUrlParams({ remember_me: 1 }, href)); } else { - $(element).attr('href', href.replace('?remember_me=1', '')); + $(element).attr('href', removeParams(['remember_me'], href)); } }); } diff --git a/app/assets/javascripts/pages/sessions/new/preserve_url_fragment.js b/app/assets/javascripts/pages/sessions/new/preserve_url_fragment.js new file mode 100644 index 00000000000..e617fecaa0f --- /dev/null +++ b/app/assets/javascripts/pages/sessions/new/preserve_url_fragment.js @@ -0,0 +1,32 @@ +import { mergeUrlParams, setUrlFragment } from '~/lib/utils/url_utility'; + +/** + * Ensure the given URL fragment is preserved by appending it to sign-in/sign-up form actions and + * OAuth/SAML login links. + * + * @param fragment {string} - url fragment to be preserved + */ +export default function preserveUrlFragment(fragment = '') { + if (fragment) { + const normalFragment = fragment.replace(/^#/, ''); + + // Append the fragment to all sign-in/sign-up form actions so it is preserved when the user is + // eventually redirected back to the originally requested URL. + const forms = document.querySelectorAll('#signin-container form'); + Array.prototype.forEach.call(forms, form => { + const actionWithFragment = setUrlFragment(form.getAttribute('action'), `#${normalFragment}`); + form.setAttribute('action', actionWithFragment); + }); + + // Append a redirect_fragment query param to all oauth provider links. The redirect_fragment + // query param will be available in the omniauth callback upon successful authentication + const anchors = document.querySelectorAll('#signin-container a.oauth-login'); + Array.prototype.forEach.call(anchors, anchor => { + const newHref = mergeUrlParams( + { redirect_fragment: normalFragment }, + anchor.getAttribute('href'), + ); + anchor.setAttribute('href', newHref); + }); + } +} diff --git a/app/assets/javascripts/serverless/components/function_details.vue b/app/assets/javascripts/serverless/components/function_details.vue new file mode 100644 index 00000000000..2b1c21f041b --- /dev/null +++ b/app/assets/javascripts/serverless/components/function_details.vue @@ -0,0 +1,73 @@ +<script> +import PodBox from './pod_box.vue'; +import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + components: { + Icon, + PodBox, + ClipboardButton, + }, + props: { + func: { + type: Object, + required: true, + }, + }, + computed: { + name() { + return this.func.name; + }, + description() { + return this.func.description; + }, + funcUrl() { + return this.func.url; + }, + podCount() { + return this.func.podcount || 0; + }, + }, +}; +</script> + +<template> + <section id="serverless-function-details"> + <h3>{{ name }}</h3> + <div class="append-bottom-default"> + <div v-for="line in description.split('\n')" :key="line">{{ line }}<br /></div> + </div> + <div class="clipboard-group append-bottom-default"> + <div class="label label-monospace">{{ funcUrl }}</div> + <clipboard-button + :text="String(funcUrl)" + :title="s__('ServerlessDetails|Copy URL to clipboard')" + class="input-group-text js-clipboard-btn" + /> + <a + :href="funcUrl" + target="_blank" + rel="noopener noreferrer nofollow" + class="input-group-text btn btn-default" + > + <icon name="external-link" /> + </a> + </div> + + <h4>{{ s__('ServerlessDetails|Kubernetes Pods') }}</h4> + <div v-if="podCount > 0"> + <p> + <b v-if="podCount == 1">{{ podCount }} {{ s__('ServerlessDetails|pod in use') }}</b> + <b v-else>{{ podCount }} {{ s__('ServerlessDetails|pods in use') }}</b> + </p> + <pod-box :count="podCount" /> + <p> + {{ + s__('ServerlessDetails|Number of Kubernetes pods in use over time based on necessity.') + }} + </p> + </div> + <div v-else><p>No pods loaded at this time.</p></div> + </section> +</template> diff --git a/app/assets/javascripts/serverless/components/function_row.vue b/app/assets/javascripts/serverless/components/function_row.vue index 31f5427c771..44bfae388cb 100644 --- a/app/assets/javascripts/serverless/components/function_row.vue +++ b/app/assets/javascripts/serverless/components/function_row.vue @@ -15,8 +15,14 @@ export default { name() { return this.func.name; }, - url() { - return this.func.url; + description() { + return this.func.description; + }, + detailUrl() { + return this.func.detail_url; + }, + environment() { + return this.func.environment_scope; }, image() { return this.func.image; @@ -30,11 +36,20 @@ export default { <template> <div class="gl-responsive-table-row"> - <div class="table-section section-20">{{ name }}</div> - <div class="table-section section-50"> - <a :href="url">{{ url }}</a> + <div class="table-section section-20 section-wrap"> + <a :href="detailUrl">{{ name }}</a> + </div> + <div class="table-section section-10">{{ environment }}</div> + <div class="table-section section-40 section-wrap"> + <span class="line-break">{{ description }}</span> </div> <div class="table-section section-20">{{ image }}</div> <div class="table-section section-10"><timeago :time="timestamp" /></div> </div> </template> + +<style> +.line-break { + white-space: pre; +} +</style> diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue index 349e14670b1..9606a78410e 100644 --- a/app/assets/javascripts/serverless/components/functions.vue +++ b/app/assets/javascripts/serverless/components/functions.vue @@ -50,8 +50,11 @@ export default { <div class="table-section section-20" role="rowheader"> {{ s__('Serverless|Function') }} </div> - <div class="table-section section-50" role="rowheader"> - {{ s__('Serverless|Domain') }} + <div class="table-section section-10" role="rowheader"> + {{ s__('Serverless|Cluster Env') }} + </div> + <div class="table-section section-40" role="rowheader"> + {{ s__('Serverless|Description') }} </div> <div class="table-section section-20" role="rowheader"> {{ s__('Serverless|Runtime') }} diff --git a/app/assets/javascripts/serverless/components/pod_box.vue b/app/assets/javascripts/serverless/components/pod_box.vue new file mode 100644 index 00000000000..04d3641bce3 --- /dev/null +++ b/app/assets/javascripts/serverless/components/pod_box.vue @@ -0,0 +1,36 @@ +<script> +export default { + props: { + count: { + type: Number, + required: true, + }, + color: { + type: String, + required: false, + default: 'green', + }, + }, + methods: { + boxOffset(i) { + return 20 * (i - 1); + }, + }, +}; +</script> + +<template> + <svg :width="boxOffset(count + 1)" :height="20"> + <rect + v-for="i in count" + :key="i" + width="15" + height="15" + rx="5" + ry="5" + :fill="color" + :x="boxOffset(i)" + y="0" + /> + </svg> +</template> diff --git a/app/assets/javascripts/serverless/serverless_bundle.js b/app/assets/javascripts/serverless/serverless_bundle.js index 3e3b81ba247..47a510d5fb5 100644 --- a/app/assets/javascripts/serverless/serverless_bundle.js +++ b/app/assets/javascripts/serverless/serverless_bundle.js @@ -4,23 +4,65 @@ import { s__ } from '../locale'; import Flash from '../flash'; import Poll from '../lib/utils/poll'; import ServerlessStore from './stores/serverless_store'; +import ServerlessDetailsStore from './stores/serverless_details_store'; import GetFunctionsService from './services/get_functions_service'; import Functions from './components/functions.vue'; +import FunctionDetails from './components/function_details.vue'; export default class Serverless { constructor() { - const { statusPath, clustersPath, helpPath, installed } = document.querySelector( - '.js-serverless-functions-page', - ).dataset; + if (document.querySelector('.js-serverless-function-details-page') != null) { + const { + serviceName, + serviceDescription, + serviceEnvironment, + serviceUrl, + serviceNamespace, + servicePodcount, + } = document.querySelector('.js-serverless-function-details-page').dataset; + const el = document.querySelector('#js-serverless-function-details'); + this.store = new ServerlessDetailsStore(); + const { store } = this; - this.service = new GetFunctionsService(statusPath); - this.knativeInstalled = installed !== undefined; - this.store = new ServerlessStore(this.knativeInstalled, clustersPath, helpPath); - this.initServerless(); - this.functionLoadCount = 0; + const service = { + name: serviceName, + description: serviceDescription, + environment: serviceEnvironment, + url: serviceUrl, + namespace: serviceNamespace, + podcount: servicePodcount, + }; - if (statusPath && this.knativeInstalled) { - this.initPolling(); + this.store.updateDetailedFunction(service); + this.functionDetails = new Vue({ + el, + data() { + return { + state: store.state, + }; + }, + render(createElement) { + return createElement(FunctionDetails, { + props: { + func: this.state.functionDetail, + }, + }); + }, + }); + } else { + const { statusPath, clustersPath, helpPath, installed } = document.querySelector( + '.js-serverless-functions-page', + ).dataset; + + this.service = new GetFunctionsService(statusPath); + this.knativeInstalled = installed !== undefined; + this.store = new ServerlessStore(this.knativeInstalled, clustersPath, helpPath); + this.initServerless(); + this.functionLoadCount = 0; + + if (statusPath && this.knativeInstalled) { + this.initPolling(); + } } } @@ -55,7 +97,7 @@ export default class Serverless { resource: this.service, method: 'fetchData', successCallback: data => this.handleSuccess(data), - errorCallback: () => this.handleError(), + errorCallback: () => Serverless.handleError(), }); if (!Visibility.hidden()) { @@ -64,7 +106,7 @@ export default class Serverless { this.service .fetchData() .then(data => this.handleSuccess(data)) - .catch(() => this.handleError()); + .catch(() => Serverless.handleError()); } Visibility.change(() => { @@ -102,5 +144,6 @@ export default class Serverless { } this.functions.$destroy(); + this.functionDetails.$destroy(); } } diff --git a/app/assets/javascripts/serverless/stores/serverless_details_store.js b/app/assets/javascripts/serverless/stores/serverless_details_store.js new file mode 100644 index 00000000000..5394d2cded1 --- /dev/null +++ b/app/assets/javascripts/serverless/stores/serverless_details_store.js @@ -0,0 +1,11 @@ +export default class ServerlessDetailsStore { + constructor() { + this.state = { + functionDetail: {}, + }; + } + + updateDetailedFunction(func) { + this.state.functionDetail = func; + } +} diff --git a/app/assets/javascripts/terminal/terminal.js b/app/assets/javascripts/terminal/terminal.js index 560f50ebf8f..e5dd7a465ea 100644 --- a/app/assets/javascripts/terminal/terminal.js +++ b/app/assets/javascripts/terminal/terminal.js @@ -2,11 +2,13 @@ import _ from 'underscore'; import $ from 'jquery'; import { Terminal } from 'xterm'; import * as fit from 'xterm/lib/addons/fit/fit'; +import * as webLinks from 'xterm/lib/addons/webLinks/webLinks'; import { canScrollUp, canScrollDown } from '~/lib/utils/dom_utils'; const SCROLL_MARGIN = 5; Terminal.applyAddon(fit); +Terminal.applyAddon(webLinks); export default class GLTerminal { constructor(element, options = {}) { @@ -48,6 +50,7 @@ export default class GLTerminal { this.terminal.open(this.container); this.terminal.fit(); + this.terminal.webLinksInit(); this.terminal.focus(); this.socket.onopen = () => { diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js index 066a3b833d7..0cc4fd59f5e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js @@ -13,6 +13,8 @@ export default function deviseState(data) { return stateKey.conflicts; } else if (data.work_in_progress) { return stateKey.workInProgress; + } else if (this.shouldBeRebased) { + return stateKey.rebase; } else if (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) { return stateKey.pipelineFailed; } else if (this.hasMergeableDiscussionsState) { @@ -25,8 +27,6 @@ export default function deviseState(data) { return this.mergeError ? stateKey.autoMergeFailed : stateKey.mergeWhenPipelineSucceeds; } else if (!this.canMerge) { return stateKey.notAllowedToMerge; - } else if (this.shouldBeRebased) { - return stateKey.rebase; } else if (this.canBeMerged) { return stateKey.readyToMerge; } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index afcb230797a..cb01a41cb7e 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -32,6 +32,10 @@ max-height: $dropdown-max-height; overflow-y: auto; + &.dropdown-extended-height { + max-height: 400px; + } + @include media-breakpoint-down(xs) { width: 100%; } diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 5574873fa22..36dd1cee4de 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -596,6 +596,10 @@ .emoji-menu-toggle-button { @include emoji-menu-toggle-button; } + + .input-group { + height: 34px; + } } .nav-links > li > a { diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 37984a8666f..c5a0eaaf704 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -29,10 +29,6 @@ .dropdown-menu-issues-board-new { width: 320px; - .open & { - max-height: 400px; - } - .dropdown-content { max-height: 162px; } diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 1352a004206..bfdc2366239 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -272,7 +272,13 @@ } .retry-link { - display: none; + display: block; + + .btn { + i { + margin-left: 5px; + } + } .btn-inverted-secondary { color: $blue-500; @@ -281,16 +287,6 @@ color: $white-light; } } - - @include media-breakpoint-down(sm) { - display: block; - - .btn { - i { - margin-left: 5px; - } - } - } } .stage-item { diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 5b30295adf9..86f571dd90d 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -1,10 +1,6 @@ /** * Note Form */ -.comment-btn { - @extend .btn-success; -} - .diff-file .diff-content { tr.line_holder:hover > td .line_note_link { opacity: 1; @@ -386,7 +382,7 @@ table { } .comment-type-dropdown { - .comment-btn { + .btn-success { width: auto; } @@ -417,7 +413,7 @@ table { width: 100%; margin-bottom: 10px; - .comment-btn { + .btn-success { flex-grow: 1; flex-shrink: 0; width: auto; diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 004c49dd226..2d28333689f 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -219,7 +219,7 @@ color: $gl-text-color-secondary; } - .project-tag-list { + .project-topic-list { font-size: $gl-font-size; font-weight: $gl-font-weight-normal; @@ -251,7 +251,7 @@ line-height: $gl-font-size-large; } - .project-tag-list, + .project-topic-list, .project-metadata { font-size: $gl-font-size-small; } @@ -273,7 +273,7 @@ } .access-request-link, - .project-tag-list { + .project-topic-list { padding-left: $gl-padding-8; border-left: 1px solid $gl-text-color-secondary; } diff --git a/app/controllers/admin/requests_profiles_controller.rb b/app/controllers/admin/requests_profiles_controller.rb index 57f7d3e3951..89d4c4f18d9 100644 --- a/app/controllers/admin/requests_profiles_controller.rb +++ b/app/controllers/admin/requests_profiles_controller.rb @@ -11,7 +11,7 @@ class Admin::RequestsProfilesController < Admin::ApplicationController profile = Gitlab::RequestProfiler::Profile.find(clean_name) if profile - render html: profile.content + render html: profile.content.html_safe else redirect_to admin_requests_profiles_path, alert: 'Profile not found' end diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index 30be50d4595..f8e482937d5 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -75,6 +75,10 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController private def omniauth_flow(auth_module, identity_linker: nil) + if fragment = request.env.dig('omniauth.params', 'redirect_fragment').presence + store_redirect_fragment(fragment) + end + if current_user log_audit_event(current_user, with: oauth['provider']) @@ -189,4 +193,13 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController request_params = request.env['omniauth.params'] (request_params['remember_me'] == '1') if request_params.present? end + + def store_redirect_fragment(redirect_fragment) + key = stored_location_key_for(:user) + location = session[key] + if uri = parse_uri(location) + uri.fragment = redirect_fragment + store_location_for(:user, uri.to_s) + end + end end diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb index 9a0997e92ee..2ef18d900f2 100644 --- a/app/controllers/projects/artifacts_controller.rb +++ b/app/controllers/projects/artifacts_controller.rb @@ -86,7 +86,7 @@ class Projects::ArtifactsController < Projects::ApplicationController end def build_from_id - project.get_build(params[:job_id]) if params[:job_id] + project.builds.find_by_id(params[:job_id]) if params[:job_id] end def build_from_ref diff --git a/app/controllers/projects/build_artifacts_controller.rb b/app/controllers/projects/build_artifacts_controller.rb index d3d5ba5c75d..4274c356227 100644 --- a/app/controllers/projects/build_artifacts_controller.rb +++ b/app/controllers/projects/build_artifacts_controller.rb @@ -45,7 +45,7 @@ class Projects::BuildArtifactsController < Projects::ApplicationController end def job_from_id - project.get_build(params[:build_id]) if params[:build_id] + project.builds.find_by_id(params[:build_id]) if params[:build_id] end def job_from_ref diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb index 62bdc84b41a..4c39ee4045f 100644 --- a/app/controllers/projects/releases_controller.rb +++ b/app/controllers/projects/releases_controller.rb @@ -4,16 +4,7 @@ class Projects::ReleasesController < Projects::ApplicationController # Authorize before_action :require_non_empty_project before_action :authorize_read_release! - before_action :check_releases_page_feature_flag def index end - - private - - def check_releases_page_feature_flag - return render_404 unless Feature.enabled?(:releases_page, @project) - - push_frontend_feature_flag(:releases_page, @project) - end end diff --git a/app/controllers/projects/serverless/functions_controller.rb b/app/controllers/projects/serverless/functions_controller.rb index 0af2b7ef343..39eca10134f 100644 --- a/app/controllers/projects/serverless/functions_controller.rb +++ b/app/controllers/projects/serverless/functions_controller.rb @@ -7,19 +7,17 @@ module Projects before_action :authorize_read_cluster! - INDEX_PRIMING_INTERVAL = 10_000 - INDEX_POLLING_INTERVAL = 30_000 + INDEX_PRIMING_INTERVAL = 15_000 + INDEX_POLLING_INTERVAL = 60_000 def index - finder = Projects::Serverless::FunctionsFinder.new(project.clusters) - respond_to do |format| format.json do functions = finder.execute if functions.any? Gitlab::PollingInterval.set_header(response, interval: INDEX_POLLING_INTERVAL) - render json: Projects::Serverless::ServiceSerializer.new(current_user: @current_user).represent(functions) + render json: serialize_function(functions) else Gitlab::PollingInterval.set_header(response, interval: INDEX_PRIMING_INTERVAL) head :no_content @@ -32,6 +30,29 @@ module Projects end end end + + def show + @service = serialize_function(finder.service(params[:environment_id], params[:id])) + return not_found if @service.nil? + + respond_to do |format| + format.json do + render json: @service + end + + format.html + end + end + + private + + def finder + Projects::Serverless::FunctionsFinder.new(project.clusters) + end + + def serialize_function(function) + Projects::Serverless::ServiceSerializer.new(current_user: @current_user, project: project).represent(function) + end end end end diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index b73a3fa6e01..1a69ec85d18 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -149,6 +149,18 @@ class IssuableFinder end end + def related_groups + if project? && project && project.group && Ability.allowed?(current_user, :read_group, project.group) + project.group.self_and_ancestors + elsif group + [group] + elsif current_user + Gitlab::ObjectHierarchy.new(current_user.authorized_groups, current_user.groups).all_objects + else + [] + end + end + def project? params[:project_id].present? end @@ -163,8 +175,10 @@ class IssuableFinder end # rubocop: disable CodeReuse/ActiveRecord - def projects(items = nil) - return @projects = project if project? + def projects + return @projects if defined?(@projects) + + return @projects = [project] if project? projects = if current_user && params[:authorized_only].presence && !current_user_related? @@ -459,7 +473,7 @@ class IssuableFinder elsif filter_by_any_milestone? items = items.any_milestone elsif filter_by_upcoming_milestone? - upcoming_ids = Milestone.upcoming_ids_by_projects(projects(items)) + upcoming_ids = Milestone.upcoming_ids(projects, related_groups) items = items.left_joins_milestones.where(milestone_id: upcoming_ids) elsif filter_by_started_milestone? items = items.left_joins_milestones.where('milestones.start_date <= NOW()') diff --git a/app/finders/projects/serverless/functions_finder.rb b/app/finders/projects/serverless/functions_finder.rb index 2b5d67e79d7..2f2816a4a08 100644 --- a/app/finders/projects/serverless/functions_finder.rb +++ b/app/finders/projects/serverless/functions_finder.rb @@ -15,11 +15,40 @@ module Projects clusters_with_knative_installed.exists? end + def service(environment_scope, name) + knative_service(environment_scope, name)&.first + end + private + def knative_service(environment_scope, name) + clusters_with_knative_installed.preload_knative.map do |cluster| + next if environment_scope != cluster.environment_scope + + services = cluster.application_knative.services_for(ns: cluster.platform_kubernetes&.actual_namespace) + .select { |svc| svc["metadata"]["name"] == name } + + add_metadata(cluster, services).first unless services.nil? + end + end + def knative_services clusters_with_knative_installed.preload_knative.map do |cluster| - cluster.application_knative.services_for(ns: cluster.platform_kubernetes&.actual_namespace) + services = cluster.application_knative.services_for(ns: cluster.platform_kubernetes&.actual_namespace) + add_metadata(cluster, services) unless services.nil? + end + end + + def add_metadata(cluster, services) + services.each do |s| + s["environment_scope"] = cluster.environment_scope + s["cluster_id"] = cluster.id + + if services.length == 1 + s["podcount"] = cluster.application_knative.service_pod_details( + cluster.platform_kubernetes&.actual_namespace, + s["metadata"]["name"]).length + end end end diff --git a/app/helpers/projects/error_tracking_helper.rb b/app/helpers/projects/error_tracking_helper.rb new file mode 100644 index 00000000000..6daf2e21ca2 --- /dev/null +++ b/app/helpers/projects/error_tracking_helper.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Projects::ErrorTrackingHelper + def error_tracking_data(project) + error_tracking_enabled = !!project.error_tracking_setting&.enabled? + + { + 'index-path' => project_error_tracking_index_path(project, + format: :json), + 'enable-error-tracking-link' => project_settings_operations_path(project), + 'error-tracking-enabled' => error_tracking_enabled.to_s, + 'illustration-path' => image_path('illustrations/cluster_popover.svg') + } + end +end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index e67c327f7f8..ebbed08f78a 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -335,6 +335,7 @@ module ProjectsHelper builds: :read_build, clusters: :read_cluster, serverless: :read_cluster, + error_tracking: :read_sentry_issue, labels: :read_label, issues: :read_issue, project_members: :read_project_member, @@ -579,6 +580,7 @@ module ProjectsHelper environments clusters functions + error_tracking user gcp ] diff --git a/app/models/clusters/applications/knative.rb b/app/models/clusters/applications/knative.rb index c572c8bff44..8d79b041b64 100644 --- a/app/models/clusters/applications/knative.rb +++ b/app/models/clusters/applications/knative.rb @@ -41,6 +41,8 @@ module Clusters scope :for_cluster, -> (cluster) { where(cluster: cluster) } + after_save :clear_reactive_cache! + def chart 'knative/knative' end @@ -79,7 +81,7 @@ module Clusters end def calculate_reactive_cache - { services: read_services } + { services: read_services, pods: read_pods } end def ingress_service @@ -87,7 +89,7 @@ module Clusters end def services_for(ns: namespace) - return unless services + return [] unless services return [] unless ns services.select do |service| @@ -95,8 +97,22 @@ module Clusters end end + def service_pod_details(ns, service) + with_reactive_cache do |data| + data[:pods].select { |pod| filter_pods(pod, ns, service) } + end + end + private + def read_pods + cluster.kubeclient.core_client.get_pods.as_json + end + + def filter_pods(pod, namespace, service) + pod["metadata"]["namespace"] == namespace && pod["metadata"]["labels"]["serving.knative.dev/service"] == service + end + def read_services client.get_services.as_json rescue Kubeclient::ResourceNotFoundError diff --git a/app/models/concerns/manual_inverse_association.rb b/app/models/concerns/manual_inverse_association.rb index e18edd33ba7..ff61412767e 100644 --- a/app/models/concerns/manual_inverse_association.rb +++ b/app/models/concerns/manual_inverse_association.rb @@ -5,8 +5,8 @@ module ManualInverseAssociation class_methods do def manual_inverse_association(association, inverse) - define_method(association) do |*args| - super(*args).tap do |value| + define_method(association) do + super().tap do |value| next unless value child_association = value.association(inverse) diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 5310f2ee765..a9d1ece0d7e 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -1108,9 +1108,10 @@ class MergeRequest < ActiveRecord::Base end def update_head_pipeline - self.head_pipeline = find_actual_head_pipeline - - update_column(:head_pipeline_id, head_pipeline.id) if head_pipeline_id_changed? + find_actual_head_pipeline.try do |pipeline| + self.head_pipeline = pipeline + update_column(:head_pipeline_id, head_pipeline.id) if head_pipeline_id_changed? + end end def merge_request_pipeline_exists? diff --git a/app/models/milestone.rb b/app/models/milestone.rb index f55c39d9912..1ebcbcda0d8 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -38,12 +38,14 @@ class Milestone < ActiveRecord::Base scope :closed, -> { with_state(:closed) } scope :for_projects, -> { where(group: nil).includes(:project) } - scope :for_projects_and_groups, -> (project_ids, group_ids) do - conditions = [] - conditions << arel_table[:project_id].in(project_ids) if project_ids&.compact&.any? - conditions << arel_table[:group_id].in(group_ids) if group_ids&.compact&.any? + scope :for_projects_and_groups, -> (projects, groups) do + projects = projects.compact if projects.is_a? Array + projects = [] if projects.nil? - where(conditions.reduce(:or)) + groups = groups.compact if groups.is_a? Array + groups = [] if groups.nil? + + where(project: projects).or(where(group: groups)) end scope :order_by_name_asc, -> { order(Arel::Nodes::Ascending.new(arel_table[:title].lower)) } @@ -133,18 +135,29 @@ class Milestone < ActiveRecord::Base @link_reference_pattern ||= super("milestones", /(?<milestone>\d+)/) end - def self.upcoming_ids_by_projects(projects) - rel = unscoped.of_projects(projects).active.where('due_date > ?', Time.now) + def self.upcoming_ids(projects, groups) + rel = unscoped + .for_projects_and_groups(projects, groups) + .active.where('milestones.due_date > NOW()') if Gitlab::Database.postgresql? - rel.order(:project_id, :due_date).select('DISTINCT ON (project_id) id') + rel.order(:project_id, :group_id, :due_date).select('DISTINCT ON (project_id, group_id) id') else + # We need to use MySQL's NULL-safe comparison operator `<=>` here + # because one of `project_id` or `group_id` is always NULL + join_clause = <<~HEREDOC + LEFT OUTER JOIN milestones earlier_milestones + ON milestones.project_id <=> earlier_milestones.project_id + AND milestones.group_id <=> earlier_milestones.group_id + AND milestones.due_date > earlier_milestones.due_date + AND earlier_milestones.due_date > NOW() + AND earlier_milestones.state = 'active' + HEREDOC + rel - .group(:project_id, :due_date, :id) - .having('due_date = MIN(due_date)') - .pluck(:id, :project_id, :due_date) - .uniq(&:second) - .map(&:first) + .joins(join_clause) + .where('earlier_milestones.id IS NULL') + .select(:id) end end diff --git a/app/models/pool_repository.rb b/app/models/pool_repository.rb index ad6a008dee8..34220c1b450 100644 --- a/app/models/pool_repository.rb +++ b/app/models/pool_repository.rb @@ -85,7 +85,11 @@ class PoolRepository < ActiveRecord::Base def unlink_repository(repository) object_pool.unlink_repository(repository.raw) - mark_obsolete unless member_projects.where.not(id: repository.project.id).exists? + if member_projects.where.not(id: repository.project.id).exists? + true + else + mark_obsolete + end end def object_pool diff --git a/app/models/project.rb b/app/models/project.rb index a66ed6736ca..7ab2fc30c24 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -73,7 +73,7 @@ class Project < ActiveRecord::Base delegate :no_import?, to: :import_state, allow_nil: true default_value_for :archived, false - default_value_for :visibility_level, gitlab_config_features.visibility_level + default_value_for(:visibility_level) { Gitlab::CurrentSettings.default_project_visibility } default_value_for :resolve_outdated_diff_discussions, false default_value_for :container_registry_enabled, gitlab_config_features.container_registry default_value_for(:repository_storage) { Gitlab::CurrentSettings.pick_repository_storage } @@ -658,10 +658,6 @@ class Project < ActiveRecord::Base latest_successful_build_for(job_name, ref) || raise(ActiveRecord::RecordNotFound.new("Couldn't find job #{job_name}")) end - def get_build(id) - builds.find_by(id: id) - end - def merge_base_commit(first_commit_id, second_commit_id) sha = repository.merge_base(first_commit_id, second_commit_id) commit_by(oid: sha) if sha @@ -2040,7 +2036,7 @@ class Project < ActiveRecord::Base end def leave_pool_repository - pool_repository&.unlink_repository(repository) + pool_repository&.unlink_repository(repository) && update_column(:pool_repository_id, nil) end private diff --git a/app/models/project_services/teamcity_service.rb b/app/models/project_services/teamcity_service.rb index b8e17087db5..3245cd22e73 100644 --- a/app/models/project_services/teamcity_service.rb +++ b/app/models/project_services/teamcity_service.rb @@ -39,9 +39,7 @@ class TeamcityService < CiService end def help - 'The build configuration in Teamcity must use the build format '\ - 'number %build.vcs.number% '\ - 'you will also want to configure monitoring of all branches so merge '\ + 'You will want to configure monitoring of all branches so merge '\ 'requests build, that setting is in the vsc root advanced settings.' end @@ -70,7 +68,7 @@ class TeamcityService < CiService end def calculate_reactive_cache(sha, ref) - response = get_path("httpAuth/app/rest/builds/branch:unspecified:any,number:#{sha}") + response = get_path("httpAuth/app/rest/builds/branch:unspecified:any,revision:#{sha}") { build_page: read_build_page(response), commit_status: read_commit_status(response) } end diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index 9bd64ea217e..ea1d941cf83 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -13,7 +13,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated presents :project AnchorData = Struct.new(:is_link, :label, :link, :class_modifier, :icon) - MAX_TAGS_TO_SHOW = 3 + MAX_TOPICS_TO_SHOW = 3 def statistic_icon(icon_name = 'plus-square-o') sprite_icon(icon_name, size: 16, css_class: 'icon append-right-4') @@ -310,20 +310,20 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated end end - def tags_to_show - project.tag_list.take(MAX_TAGS_TO_SHOW) # rubocop: disable CodeReuse/ActiveRecord + def topics_to_show + project.tag_list.take(MAX_TOPICS_TO_SHOW) # rubocop: disable CodeReuse/ActiveRecord end - def count_of_extra_tags_not_shown - if project.tag_list.count > MAX_TAGS_TO_SHOW - project.tag_list.count - MAX_TAGS_TO_SHOW + def count_of_extra_topics_not_shown + if project.tag_list.count > MAX_TOPICS_TO_SHOW + project.tag_list.count - MAX_TOPICS_TO_SHOW else 0 end end - def has_extra_tags? - count_of_extra_tags_not_shown > 0 + def has_extra_topics? + count_of_extra_topics_not_shown > 0 end private diff --git a/app/serializers/projects/serverless/service_entity.rb b/app/serializers/projects/serverless/service_entity.rb index 4f1f62d145b..c98dc1a1c4a 100644 --- a/app/serializers/projects/serverless/service_entity.rb +++ b/app/serializers/projects/serverless/service_entity.rb @@ -13,6 +13,25 @@ module Projects service.dig('metadata', 'namespace') end + expose :environment_scope do |service| + service.dig('environment_scope') + end + + expose :cluster_id do |service| + service.dig('cluster_id') + end + + expose :detail_url do |service| + project_serverless_path( + request.project, + service.dig('environment_scope'), + service.dig('metadata', 'name')) + end + + expose :podcount do |service| + service.dig('podcount') + end + expose :created_at do |service| service.dig('metadata', 'creationTimestamp') end @@ -22,11 +41,24 @@ module Projects end expose :description do |service| - service.dig('spec', 'runLatest', 'configuration', 'revisionTemplate', 'metadata', 'annotations', 'Description') + service.dig( + 'spec', + 'runLatest', + 'configuration', + 'revisionTemplate', + 'metadata', + 'annotations', + 'Description') end expose :image do |service| - service.dig('spec', 'runLatest', 'configuration', 'build', 'template', 'name') + service.dig( + 'spec', + 'runLatest', + 'configuration', + 'build', + 'template', + 'name') end end end diff --git a/app/services/ci/destroy_pipeline_service.rb b/app/services/ci/destroy_pipeline_service.rb index 13f892aabb8..5c4a34043c1 100644 --- a/app/services/ci/destroy_pipeline_service.rb +++ b/app/services/ci/destroy_pipeline_service.rb @@ -5,8 +5,6 @@ module Ci def execute(pipeline) raise Gitlab::Access::AccessDeniedError unless can?(current_user, :destroy_pipeline, pipeline) - AuditEventService.new(current_user, pipeline).security_event - pipeline.destroy! end end diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml index 34d4293bd45..30ed7ed6b29 100644 --- a/app/views/devise/sessions/new.html.haml +++ b/app/views/devise/sessions/new.html.haml @@ -1,6 +1,6 @@ - page_title "Sign in" -%div +#signin-container - if form_based_providers.any? = render 'devise/shared/tabs_ldap' - else diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index e516c76400a..d62cbc1684b 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -29,7 +29,7 @@ = link_to activity_project_path(@project), title: _('Activity'), class: 'shortcuts-project-activity' do %span= _('Activity') - - if project_nav_tab?(:releases) && Feature.enabled?(:releases_page, @project) + - if project_nav_tab?(:releases) = nav_link(controller: :releases) do = link_to project_releases_path(@project), title: _('Releases'), class: 'shortcuts-project-releases' do %span= _('Releases') @@ -227,6 +227,12 @@ %span = _('Environments') + - if project_nav_tab?(:error_tracking) && Feature.enabled?(:error_tracking, @project) + = nav_link(controller: :error_tracking) do + = link_to project_error_tracking_index_path(@project), title: _('Error Tracking'), class: 'shortcuts-tracking qa-operations-tracking-link' do + %span + = _('Error Tracking') + - if project_nav_tab? :serverless = nav_link(controller: :functions) do = link_to project_serverless_functions_path(@project), title: _('Serverless') do diff --git a/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml index aa980da7e95..91deffe07c1 100644 --- a/app/views/projects/_export.html.haml +++ b/app/views/projects/_export.html.haml @@ -19,7 +19,7 @@ %ul %li Project and wiki repositories %li Project uploads - %li Project configuration including web hooks and services + %li Project configuration, including services %li Issues with comments, merge requests with diffs and comments, labels, milestones, snippets, and other project entities %li LFS objects %p @@ -28,6 +28,7 @@ %li Job traces and artifacts %li Container registry images %li CI variables + %li Webhooks %li Any encrypted tokens %p Once the exported file is ready, you will receive a notification email with a download link, or you can download it from this page. diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index 82b2ab64a5d..d2587af11dd 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -19,12 +19,13 @@ %span.access-request-links.prepend-left-8 = render 'shared/members/access_request_links', source: @project - if @project.tag_list.present? - %span.project-tag-list.d-inline-flex.prepend-left-8.has-tooltip{ data: { container: 'body' }, title: @project.has_extra_tags? ? @project.tag_list.join(', ') : nil } + %span.project-topic-list.d-inline-flex.prepend-left-8.has-tooltip{ data: { container: 'body' }, title: @project.has_extra_topics? ? @project.tag_list.join(', ') : nil } = sprite_icon('tag', size: 16, css_class: 'icon append-right-4') - = @project.tags_to_show - - if @project.has_extra_tags? + = @project.topics_to_show + - if @project.has_extra_topics? = _("+ %{count} more") % { count: @project.count_of_extra_tags_not_shown } + .project-repo-buttons.col-md-12.col-lg-6.d-inline-flex.flex-wrap.justify-content-lg-end - if current_user .d-inline-flex diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index 1b52821af15..a58f736b5b4 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -39,9 +39,9 @@ = render_if_exists 'shared/repository_size_limit_setting', form: f, type: :project .form-group - = f.label :tag_list, "Tags", class: 'label-bold' + = f.label :tag_list, "Topics", class: 'label-bold' = f.text_field :tag_list, value: @project.tag_list.join(', '), maxlength: 2000, class: "form-control" - %p.form-text.text-muted Separate tags with commas. + %p.form-text.text-muted Separate topics with commas. %fieldset.features %h5.prepend-top-0= _("Project avatar") .form-group diff --git a/app/views/projects/error_tracking/index.html.haml b/app/views/projects/error_tracking/index.html.haml index a3e0dc75f6f..bc02c5f0e5a 100644 --- a/app/views/projects/error_tracking/index.html.haml +++ b/app/views/projects/error_tracking/index.html.haml @@ -1 +1,3 @@ - page_title _('Errors') + +#js-error_tracking{ data: error_tracking_data(@project) } diff --git a/app/views/projects/serverless/functions/show.html.haml b/app/views/projects/serverless/functions/show.html.haml new file mode 100644 index 00000000000..29737b7014a --- /dev/null +++ b/app/views/projects/serverless/functions/show.html.haml @@ -0,0 +1,16 @@ +- @no_container = true +- @content_class = "limit-container-width" unless fluid_layout + +- add_to_breadcrumbs('Serverless', project_serverless_functions_path(@project)) + +- page_title @service[:name] + +.serverless-function-details-page.js-serverless-function-details-page{ data: { service: @service.as_json } } +%div{ class: [container_class, ('limit-container-width' unless fluid_layout)] } + .top-area.adjust + .serverless-function-details#js-serverless-function-details + + .js-serverless-function-notice + .flash-container + + .function-holder.js-function-holder.input-group diff --git a/app/views/projects/settings/operations/_error_tracking.html.haml b/app/views/projects/settings/operations/_error_tracking.html.haml index 71335e4dfd0..871b60f05ba 100644 --- a/app/views/projects/settings/operations/_error_tracking.html.haml +++ b/app/views/projects/settings/operations/_error_tracking.html.haml @@ -18,7 +18,7 @@ = form.label :enabled, _('Active'), class: 'form-check-label' .form-group = form.label :api_url, _('Sentry API URL'), class: 'label-bold' - = form.url_field :api_url, class: 'form-control', placeholder: _('http://<sentry-host>/api/0/projects/{organization_slug}/{project_slug}/issues/') + = form.url_field :api_url, class: 'form-control', placeholder: _('http://<sentry-host>/api/0/projects/{organization_slug}/{project_slug}/') %p.form-text.text-muted = _('Enter your Sentry API URL') .form-group diff --git a/app/views/shared/empty_states/_issues.html.haml b/app/views/shared/empty_states/_issues.html.haml index 0434860dec4..2691ec4cd46 100644 --- a/app/views/shared/empty_states/_issues.html.haml +++ b/app/views/shared/empty_states/_issues.html.haml @@ -2,6 +2,10 @@ - project_select_button = local_assigns.fetch(:project_select_button, false) - show_import_button = local_assigns.fetch(:show_import_button, false) && Feature.enabled?(:issues_import_csv) && can?(current_user, :import_issues, @project) - has_button = button_path || project_select_button +- closed_issues_count = issuables_count_for_state(:issues, :closed) +- opened_issues_count = issuables_count_for_state(:issues, :opened) +- is_opened_state = params[:state] == 'opened' +- is_closed_state = params[:state] == 'closed' .row.empty-state .col-12 @@ -14,6 +18,20 @@ = _("Sorry, your filter produced no results") %p.text-center = _("To widen your search, change or remove filters above") + - if show_new_issue_link?(@project) + .text-center + = link_to _("New issue"), new_project_issue_path(@project), class: "btn btn-success", title: _("New issue"), id: "new_issue_body_link" + - elsif is_opened_state && opened_issues_count == 0 && closed_issues_count > 0 + %h4.text-center + = _("There are no open issues") + %p.text-center + = _("To keep this project going, create a new issue") + - if show_new_issue_link?(@project) + .text-center + = link_to _("New issue"), new_project_issue_path(@project), class: "btn btn-success", title: _("New issue"), id: "new_issue_body_link" + - elsif is_closed_state && opened_issues_count > 0 && closed_issues_count == 0 + %h4.text-center + = _("There are no closed issues") - elsif current_user %h4 = _("The Issue Tracker is the place to add things that need to be improved or solved in a project") diff --git a/app/views/shared/empty_states/_merge_requests.html.haml b/app/views/shared/empty_states/_merge_requests.html.haml index 06ceb9738bc..be5b1c6b6ce 100644 --- a/app/views/shared/empty_states/_merge_requests.html.haml +++ b/app/views/shared/empty_states/_merge_requests.html.haml @@ -1,6 +1,11 @@ - button_path = local_assigns.fetch(:button_path, false) - project_select_button = local_assigns.fetch(:project_select_button, false) - has_button = button_path || project_select_button +- closed_merged_count = issuables_count_for_state(:merged, :closed) +- opened_merged_count = issuables_count_for_state(:merged, :opened) +- is_opened_state = params[:state] == 'opened' +- is_closed_state = params[:state] == 'closed' +- can_create_merge_request = merge_request_source_project_for_project(@project) .row.empty-state.merge-requests .col-12 @@ -13,6 +18,20 @@ = _("Sorry, your filter produced no results") %p.text-center = _("To widen your search, change or remove filters above") + .text-center + - if can_create_merge_request + = link_to _("New merge request"), project_new_merge_request_path(@project), class: "btn btn-success", title: _("New merge request"), id: "new_merge_request_body_link" + - elsif is_opened_state && opened_merged_count == 0 && closed_merged_count > 0 + %h4.text-center + = _("There are no open merge requests") + %p.text-center + = _("To keep this project going, create a new merge request") + .text-center + - if can_create_merge_request + = link_to _("New merge request"), project_new_merge_request_path(@project), class: "btn btn-success", title: _("New merge request"), id: "new_merge_request_body_link" + - elsif is_closed_state && opened_merged_count > 0 && closed_merged_count == 0 + %h4.text-center + = _("There are no closed merge requests") - else %h4 = _("Merge requests are a place to propose changes you've made to a project and discuss those changes with others") diff --git a/app/views/shared/issuable/_board_create_list_dropdown.html.haml b/app/views/shared/issuable/_board_create_list_dropdown.html.haml index 4597d9439fa..fd413bd68c8 100644 --- a/app/views/shared/issuable/_board_create_list_dropdown.html.haml +++ b/app/views/shared/issuable/_board_create_list_dropdown.html.haml @@ -1,7 +1,7 @@ .dropdown.prepend-left-10#js-add-list %button.btn.btn-success.btn-inverted.js-new-board-list{ type: "button", data: board_list_data } Add list - .dropdown-menu.dropdown-menu-paging.dropdown-menu-right.dropdown-menu-issues-board-new.dropdown-menu-selectable.js-tab-container-labels + .dropdown-menu.dropdown-extended-height.dropdown-menu-paging.dropdown-menu-right.dropdown-menu-issues-board-new.dropdown-menu-selectable.js-tab-container-labels = render partial: "shared/issuable/label_page_default", locals: { show_footer: true, show_create: true, show_boards_content: true, title: "Add list" } - if can?(current_user, :admin_label, board.parent) = render partial: "shared/issuable/label_page_create" diff --git a/app/views/shared/notes/_comment_button.html.haml b/app/views/shared/notes/_comment_button.html.haml index f487c0bf0d5..c3f5eeb0da6 100644 --- a/app/views/shared/notes/_comment_button.html.haml +++ b/app/views/shared/notes/_comment_button.html.haml @@ -1,10 +1,10 @@ - noteable_name = @note.noteable.human_class_name .float-left.btn-group.append-right-10.droplab-dropdown.comment-type-dropdown.js-comment-type-dropdown - %input.btn.btn-nr.btn-success.comment-btn.js-comment-button.js-comment-submit-button{ type: 'submit', value: _('Comment') } + %input.btn.btn-nr.btn-success.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 + = button_tag type: 'button', class: 'btn btn-nr dropdown-toggle btn-success js-note-new-discussion js-disable-on-submit', data: { 'dropdown-trigger' => '#resolvable-comment-menu' }, 'aria-label' => _('Open comment type dropdown') do = icon('caret-down', class: 'toggle-icon') %ul#resolvable-comment-menu.dropdown-menu.dropdown-open-top{ data: { dropdown: true } } diff --git a/changelogs/unreleased/25043-empty-states.yml b/changelogs/unreleased/25043-empty-states.yml new file mode 100644 index 00000000000..529a8b3206f --- /dev/null +++ b/changelogs/unreleased/25043-empty-states.yml @@ -0,0 +1,5 @@ +--- +title: Make issuable empty states actionable +merge_request: 24077 +author: +type: changed diff --git a/changelogs/unreleased/37990-task-list-bracket.yml b/changelogs/unreleased/37990-task-list-bracket.yml new file mode 100644 index 00000000000..ffa77cf0af7 --- /dev/null +++ b/changelogs/unreleased/37990-task-list-bracket.yml @@ -0,0 +1,5 @@ +--- +title: Fix ambiguous brackets in task lists +merge_request: 18514 +author: Jared Deckard <jared.deckard@gmail.com> +type: fixed diff --git a/changelogs/unreleased/45779-fix-default-visibility-level-for-projects.yml b/changelogs/unreleased/45779-fix-default-visibility-level-for-projects.yml new file mode 100644 index 00000000000..b4cba5041d1 --- /dev/null +++ b/changelogs/unreleased/45779-fix-default-visibility-level-for-projects.yml @@ -0,0 +1,5 @@ +--- +title: Fix default visibility_level for new projects +merge_request: 24120 +author: Fabian Schneider @fabsrc +type: fixed diff --git a/changelogs/unreleased/53431-fix-upcoming-milestone-filter-for-groups.yml b/changelogs/unreleased/53431-fix-upcoming-milestone-filter-for-groups.yml new file mode 100644 index 00000000000..1e9c7f3913c --- /dev/null +++ b/changelogs/unreleased/53431-fix-upcoming-milestone-filter-for-groups.yml @@ -0,0 +1,5 @@ +--- +title: Fix upcoming milestones filter not including group milestones +merge_request: 23098 +author: Heinrich Lee Yu +type: fixed diff --git a/changelogs/unreleased/54167-rename-project-tags-to-project-topics.yml b/changelogs/unreleased/54167-rename-project-tags-to-project-topics.yml new file mode 100644 index 00000000000..6fc8aa1a195 --- /dev/null +++ b/changelogs/unreleased/54167-rename-project-tags-to-project-topics.yml @@ -0,0 +1,5 @@ +--- +title: Rename project tags to project topics +merge_request: 24219 +author: +type: other diff --git a/changelogs/unreleased/54484-anchor-links-to-comments-or-system-notes-can-break-with-discussion-filters.yml b/changelogs/unreleased/54484-anchor-links-to-comments-or-system-notes-can-break-with-discussion-filters.yml new file mode 100644 index 00000000000..4d543db567d --- /dev/null +++ b/changelogs/unreleased/54484-anchor-links-to-comments-or-system-notes-can-break-with-discussion-filters.yml @@ -0,0 +1,5 @@ +--- +title: Ensured links to a comment or system note anchor resolves to the right note if a user has a discussion filter. +merge_request: 24228 +author: +type: changed diff --git a/changelogs/unreleased/55495-teamcity-use-revision-in-query.yml b/changelogs/unreleased/55495-teamcity-use-revision-in-query.yml new file mode 100644 index 00000000000..724de733b7c --- /dev/null +++ b/changelogs/unreleased/55495-teamcity-use-revision-in-query.yml @@ -0,0 +1,5 @@ +--- +title: Build number does not need to be tweaked anymore for the TeamCity integration to work properly. +merge_request: 23898 +author: +type: changed diff --git a/changelogs/unreleased/55884-adjust-emoji-and-cancel-buttons-height-in-user-status-modal-when-emoji-is-changed.yml b/changelogs/unreleased/55884-adjust-emoji-and-cancel-buttons-height-in-user-status-modal-when-emoji-is-changed.yml new file mode 100644 index 00000000000..2fbf334f5e9 --- /dev/null +++ b/changelogs/unreleased/55884-adjust-emoji-and-cancel-buttons-height-in-user-status-modal-when-emoji-is-changed.yml @@ -0,0 +1,5 @@ +--- +title: Emoji and cancel button are taller than input in set user status modal +merge_request: 24173 +author: Dhiraj Bodicherla +type: fixed diff --git a/changelogs/unreleased/56110-cluster-kubernetes-api-500-error-on-post-request.yml b/changelogs/unreleased/56110-cluster-kubernetes-api-500-error-on-post-request.yml new file mode 100644 index 00000000000..4da14114225 --- /dev/null +++ b/changelogs/unreleased/56110-cluster-kubernetes-api-500-error-on-post-request.yml @@ -0,0 +1,5 @@ +--- +title: Improves restriction of multiple Kubernetes clusters through API +merge_request: 24251 +author: +type: fixed diff --git a/changelogs/unreleased/56172-docs-fix-add-include-to-ci-param-list.yml b/changelogs/unreleased/56172-docs-fix-add-include-to-ci-param-list.yml new file mode 100644 index 00000000000..92592290ac4 --- /dev/null +++ b/changelogs/unreleased/56172-docs-fix-add-include-to-ci-param-list.yml @@ -0,0 +1,5 @@ +--- +title: Update CI YAML param table with include +merge_request: !24309 +author: +type: fixed diff --git a/changelogs/unreleased/deprecated-force-reload.yml b/changelogs/unreleased/deprecated-force-reload.yml new file mode 100644 index 00000000000..2a0e97089e0 --- /dev/null +++ b/changelogs/unreleased/deprecated-force-reload.yml @@ -0,0 +1,6 @@ +--- +title: 'Fix deprecation: Passing an argument to force an association to reload is + now deprecated' +merge_request: 24136 +author: Jasper Maes +type: other diff --git a/changelogs/unreleased/error_tracking_feature_flag_fe.yml b/changelogs/unreleased/error_tracking_feature_flag_fe.yml new file mode 100644 index 00000000000..607929eb6b8 --- /dev/null +++ b/changelogs/unreleased/error_tracking_feature_flag_fe.yml @@ -0,0 +1,5 @@ +--- +title: Display a list of Sentry Issues in GitLab +merge_request: 23770 +author: +type: added diff --git a/changelogs/unreleased/fix-udpate-head-pipeline-method.yml b/changelogs/unreleased/fix-udpate-head-pipeline-method.yml new file mode 100644 index 00000000000..8dbb9f8e42b --- /dev/null +++ b/changelogs/unreleased/fix-udpate-head-pipeline-method.yml @@ -0,0 +1,5 @@ +--- +title: Fix unexpected exception by failure of finding an actual head pipeline +merge_request: 24257 +author: +type: fixed diff --git a/changelogs/unreleased/fj-55882-fix-files-api-content-disposition.yml b/changelogs/unreleased/fj-55882-fix-files-api-content-disposition.yml new file mode 100644 index 00000000000..f64b29644b0 --- /dev/null +++ b/changelogs/unreleased/fj-55882-fix-files-api-content-disposition.yml @@ -0,0 +1,5 @@ +--- +title: Fix files/blob api endpoints content disposition +merge_request: 24267 +author: +type: fixed diff --git a/changelogs/unreleased/gt-remove-unused-button-class.yml b/changelogs/unreleased/gt-remove-unused-button-class.yml new file mode 100644 index 00000000000..f7889e1d6f6 --- /dev/null +++ b/changelogs/unreleased/gt-remove-unused-button-class.yml @@ -0,0 +1,5 @@ +--- +title: Remove unused button classes `btn-create` and `comment-btn` +merge_request: 23232 +author: George Tsiolis +type: performance diff --git a/changelogs/unreleased/iss-32584-preserve-line-number-fragment-after-redirect.yml b/changelogs/unreleased/iss-32584-preserve-line-number-fragment-after-redirect.yml new file mode 100644 index 00000000000..8025cd472bd --- /dev/null +++ b/changelogs/unreleased/iss-32584-preserve-line-number-fragment-after-redirect.yml @@ -0,0 +1,6 @@ +--- +title: Fix lost line number when navigating to a specific line in a protected file + before authenticating. +merge_request: 19165 +author: Scott Escue +type: fixed diff --git a/changelogs/unreleased/jivl-update-placeholder-sentry-config.yml b/changelogs/unreleased/jivl-update-placeholder-sentry-config.yml new file mode 100644 index 00000000000..eb860fd3905 --- /dev/null +++ b/changelogs/unreleased/jivl-update-placeholder-sentry-config.yml @@ -0,0 +1,5 @@ +--- +title: Update url placeholder for the sentry configuration page +merge_request: 24338 +author: +type: other diff --git a/changelogs/unreleased/knative-show-page.yml b/changelogs/unreleased/knative-show-page.yml new file mode 100644 index 00000000000..a48b754940f --- /dev/null +++ b/changelogs/unreleased/knative-show-page.yml @@ -0,0 +1,5 @@ +--- +title: Add Knative detailed view +merge_request: 23863 +author: Chris Baumbauer +type: added diff --git a/changelogs/unreleased/move-job-cancel-btn.yml b/changelogs/unreleased/move-job-cancel-btn.yml new file mode 100644 index 00000000000..41f8e1be5f8 --- /dev/null +++ b/changelogs/unreleased/move-job-cancel-btn.yml @@ -0,0 +1,5 @@ +--- +title: Move cancel & new issue button on job page +merge_request: 24074 +author: +type: changed diff --git a/changelogs/unreleased/mr-rebase-failing-tests.yml b/changelogs/unreleased/mr-rebase-failing-tests.yml new file mode 100644 index 00000000000..07ae05766b1 --- /dev/null +++ b/changelogs/unreleased/mr-rebase-failing-tests.yml @@ -0,0 +1,5 @@ +--- +title: Fixed rebase button not showing in merge request widget +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/notebook-multiple-outputs.yml b/changelogs/unreleased/notebook-multiple-outputs.yml new file mode 100644 index 00000000000..38cc52c0634 --- /dev/null +++ b/changelogs/unreleased/notebook-multiple-outputs.yml @@ -0,0 +1,5 @@ +--- +title: Support multiple outputs in jupyter notebooks +merge_request: +author: +type: changed diff --git a/changelogs/unreleased/sh-fix-request-profiles-html.yml b/changelogs/unreleased/sh-fix-request-profiles-html.yml new file mode 100644 index 00000000000..74e4115db8e --- /dev/null +++ b/changelogs/unreleased/sh-fix-request-profiles-html.yml @@ -0,0 +1,5 @@ +--- +title: Fix requests profiler in admin page not rendering HTML properly +merge_request: 24291 +author: +type: fixed diff --git a/changelogs/unreleased/tc-remove-20181218192239-migration.yml b/changelogs/unreleased/tc-remove-20181218192239-migration.yml new file mode 100644 index 00000000000..81e06a99c1f --- /dev/null +++ b/changelogs/unreleased/tc-remove-20181218192239-migration.yml @@ -0,0 +1,5 @@ +--- +title: Remove migration to backfill project_repositories for legacy storage projects +merge_request: 24299 +author: +type: removed diff --git a/changelogs/unreleased/update-gitlab-styles.yml b/changelogs/unreleased/update-gitlab-styles.yml new file mode 100644 index 00000000000..379f0ad4486 --- /dev/null +++ b/changelogs/unreleased/update-gitlab-styles.yml @@ -0,0 +1,5 @@ +--- +title: Update gitlab-styles to 2.5.1 +merge_request: 24336 +author: Jasper Maes +type: other diff --git a/changelogs/unreleased/winh-add-list-dropdown-height.yml b/changelogs/unreleased/winh-add-list-dropdown-height.yml new file mode 100644 index 00000000000..6bcedc15cc9 --- /dev/null +++ b/changelogs/unreleased/winh-add-list-dropdown-height.yml @@ -0,0 +1,5 @@ +--- +title: Adjust height of "Add list" dropdown in issue boards +merge_request: 24227 +author: +type: fixed diff --git a/config/routes/project.rb b/config/routes/project.rb index e6ecb4bc9d8..797bf6de37b 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -247,6 +247,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do end namespace :serverless do + get '/functions/:environment_id/:id', to: 'functions#show' resources :functions, only: [:index] end diff --git a/danger/commit_messages/Dangerfile b/danger/commit_messages/Dangerfile index 6a5a75b6eba..c20c8b77e6a 100644 --- a/danger/commit_messages/Dangerfile +++ b/danger/commit_messages/Dangerfile @@ -2,6 +2,9 @@ require 'json' +URL_LIMIT_SUBJECT = "https://chris.beams.io/posts/git-commit/#limit-50" +URL_GIT_COMMIT = "https://chris.beams.io/posts/git-commit/" + # rubocop: disable Style/SignalException # rubocop: disable Metrics/CyclomaticComplexity # rubocop: disable Metrics/PerceivedComplexity @@ -101,10 +104,7 @@ def lint_commits(commits) elsif subject.length > 50 warn_commit( commit, - "This commit's subject line could be improved. " \ - 'Commit subjects are ideally no longer than roughly 50 characters, ' \ - 'though we allow up to 72 characters in the subject. ' \ - 'If possible, try to reduce the length of the subject to roughly 50 characters.' + "This commit's subject line is acceptable, but please try to [reduce it to 50 characters](#{URL_LIMIT_SUBJECT})." ) end @@ -196,7 +196,7 @@ def lint_commits(commits) One or more commit messages do not meet our Git commit message standards. For more information on how to write a good commit message, take a look at - [How to Write a Git Commit Message](https://chris.beams.io/posts/git-commit/). + [How to Write a Git Commit Message](#{URL_GIT_COMMIT}). Here is an example of a good commit message: diff --git a/db/post_migrate/20181218192239_backfill_project_repositories_for_legacy_storage_projects.rb b/db/post_migrate/20181218192239_backfill_project_repositories_for_legacy_storage_projects.rb deleted file mode 100644 index 42f96750789..00000000000 --- a/db/post_migrate/20181218192239_backfill_project_repositories_for_legacy_storage_projects.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -class BackfillProjectRepositoriesForLegacyStorageProjects < ActiveRecord::Migration[5.0] - include Gitlab::Database::MigrationHelpers - - DOWNTIME = false - BATCH_SIZE = 1_000 - DELAY_INTERVAL = 5.minutes - MIGRATION = 'BackfillLegacyProjectRepositories' - - disable_ddl_transaction! - - class Project < ActiveRecord::Base - include EachBatch - - self.table_name = 'projects' - end - - def up - queue_background_migration_jobs_by_range_at_intervals(Project, MIGRATION, DELAY_INTERVAL) - end - - def down - # no-op: since there could have been existing rows before the migration do not remove anything - end -end diff --git a/doc/administration/operations/unicorn.md b/doc/administration/operations/unicorn.md index bad61151bda..0e2079cb093 100644 --- a/doc/administration/operations/unicorn.md +++ b/doc/administration/operations/unicorn.md @@ -60,7 +60,17 @@ Unicorn master then automatically replaces the worker process. This is a robust way to handle memory leaks: Unicorn is designed to handle workers that 'crash' so no user requests will be dropped. The unicorn-worker-killer gem is designed to only terminate a worker process _in -between requests_, so no user requests are affected. +between requests_, so no user requests are affected. You can set the minimum and +maximum memory threshold (in bytes) for the Unicorn worker killer by +setting the following values `/etc/gitlab/gitlab.rb`: + +```ruby +unicorn['worker_memory_limit_min'] = "400 * 1 << 20" +unicorn['worker_memory_limit_max'] = "650 * 1 << 20" +``` + +Otherwise, you can set the `GITLAB_UNICORN_MEMORY_MIN` and `GITLAB_UNICORN_MEMORY_MIN` +[environment variables](../environment_variables.md). This is what a Unicorn worker memory restart looks like in unicorn_stderr.log. You see that worker 4 (PID 125918) is inspecting itself and decides to exit. diff --git a/doc/api/README.md b/doc/api/README.md index 3ed1a3799c8..6c5bb1c0940 100644 --- a/doc/api/README.md +++ b/doc/api/README.md @@ -69,6 +69,9 @@ The following API resources are available: - [Sidekiq metrics](sidekiq_metrics.md) - [System hooks](system_hooks.md) - [Tags](tags.md) +- [Releases](releases/index.md) +- Release Assets + - [Links](releases/links.md) - [Todos](todos.md) - [Users](users.md) - [Validate CI configuration](lint.md) (linting) diff --git a/doc/api/applications.md b/doc/api/applications.md index 7f95c136168..82955f0c1db 100644 --- a/doc/api/applications.md +++ b/doc/api/applications.md @@ -1,28 +1,36 @@ # Applications API -> [Introduced][ce-8160] in GitLab 10.5 +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/8160) in GitLab 10.5. -[ce-8160]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/8160 +Applications API operates on OAuth applications for: -Only admin user can use the Applications API. +- [Using GitLab as an authentication provider](../integration/oauth_provider.md). +- [Allowing access to GitLab resources on a user's behalf](oauth2.md). -## Create a application +NOTE: **Note:** +Only admin users can use the Applications API. -Create a application by posting a JSON payload. +## Create an application + +Create an application by posting a JSON payload. Returns `200` if the request succeeds. -``` +```text POST /applications ``` -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `name` | string | yes | The name of the application | -| `redirect_uri` | string | yes | The redirect URI of the application | -| `scopes` | string | yes | The scopes of the application | +Parameters: + +| Attribute | Type | Required | Description | +|:---------------|:-------|:---------|:---------------------------------| +| `name` | string | yes | Name of the application. | +| `redirect_uri` | string | yes | Redirect URI of the application. | +| `scopes` | string | yes | Scopes of the application. | + +Example request: -```bash +```sh curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" --data "name=MyApplication&redirect_uri=http://redirect.uri&scopes=" https://gitlab.example.com/api/v4/applications ``` @@ -42,11 +50,13 @@ Example response: List all registered applications. -``` +```text GET /applications ``` -```bash +Example request: + +```sh curl --request GET --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/applications ``` @@ -63,7 +73,8 @@ Example response: ] ``` -> Note: the `secret` value will not be exposed by this API. +NOTE: **Note:** +The `secret` value will not be exposed by this API. ## Delete an application @@ -71,7 +82,7 @@ Delete a specific application. Returns `204` if the request succeeds. -``` +```text DELETE /applications/:id ``` @@ -79,6 +90,8 @@ Parameters: - `id` (required) - The id of the application (not the application_id) -```bash +Example request: + +```sh curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/applications/:id ``` diff --git a/doc/api/oauth2.md b/doc/api/oauth2.md index 6786c0c5b5c..6e156a14b25 100644 --- a/doc/api/oauth2.md +++ b/doc/api/oauth2.md @@ -1,126 +1,165 @@ # GitLab as an OAuth2 provider -This document covers using the [OAuth2](https://oauth.net/2/) protocol to allow other services access GitLab resources on user's behalf. +This document covers using the [OAuth2](https://oauth.net/2/) protocol to allow +other services to access GitLab resources on user's behalf. -If you want GitLab to be an OAuth authentication service provider to sign into other services please see the [OAuth2 provider](../integration/oauth_provider.md) -documentation. +If you want GitLab to be an OAuth authentication service provider to sign into +other services, see the [OAuth2 provider](../integration/oauth_provider.md) +documentation. This functionality is based on the +[doorkeeper Ruby gem](https://github.com/doorkeeper-gem/doorkeeper). -This functionality is based on [doorkeeper gem](https://github.com/doorkeeper-gem/doorkeeper). +## Supported OAuth2 flows -## Supported OAuth2 Flows +GitLab currently supports the following authorization flows: -GitLab currently supports following authorization flows: +- **Web application flow:** Most secure and common type of flow, designed for + applications with secure server-side. +- **Implicit grant flow:** This flow is designed for user-agent only apps (e.g., single + page web application running on GitLab Pages). +- **Resource owner password credentials flow:** To be used **only** for securely + hosted, first-party services. -- *Web Application Flow* - Most secure and common type of flow, designed for the applications with secure server-side. -- *Implicit Flow* - This flow is designed for user-agent only apps (e.g. single page web application running on GitLab Pages). -- *Resource Owner Password Credentials Flow* - To be used **only** for securely hosted, first-party services. +Refer to the [OAuth RFC](https://tools.ietf.org/html/rfc6749) to find out +how all those flows work and pick the right one for your use case. -Please refer to [OAuth RFC](https://tools.ietf.org/html/rfc6749) to find out in details how all those flows work and pick the right one for your use case. +Both **web application** and **implicit grant** flows require `application` to be +registered first via the `/profile/applications` page in your user's account. +During registration, by enabling proper scopes, you can limit the range of +resources which the `application` can access. Upon creation, you'll obtain the +`application` credentials: _Application ID_ and _Client Secret_ - **keep them secure**. -Both *web application* and *implicit* flows require `application` to be registered first via `/profile/applications` page -in your user's account. During registration, by enabling proper scopes you can limit the range of resources which the `application` can access. Upon creation -you'll obtain `application` credentials: _Application ID_ and _Client Secret_ - **keep them secure**. +CAUTION: **Important:** +OAuth specification advises sending the `state` parameter with each request to +`/oauth/authorize`. We highly recommended sending a unique value with each request +and validate it against the one in the redirect request. This is important in +order to prevent [CSRF attacks](https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)). +The `state` parameter really should have been a requirement in the standard! ->**Important:** OAuth specification advises sending `state` parameter with each request to `/oauth/authorize`. We highly recommended to send a unique -value with each request and validate it against the one in redirect request. This is important to prevent [CSRF attacks]. The `state` param really should -have been a requirement in the standard! +In the following sections you will find detailed instructions on how to obtain +authorization with each flow. -In the following sections you will find detailed instructions on how to obtain authorization with each flow. +### Web application flow -### Web Application Flow +NOTE: **Note:** +Check the [RFC spec](https://tools.ietf.org/html/rfc6749#section-4.1) for a +detailed flow description. -Check [RFC spec](http://tools.ietf.org/html/rfc6749#section-4.1) for a detailed flow description +The web application flow is: -#### 1. Requesting authorization code +1. Request authorization code. To do that, you should redirect the user to the + `/oauth/authorize` endpoint with the following GET parameters: -To request the authorization code, you should redirect the user to the `/oauth/authorize` endpoint with following GET parameters: + ``` + https://gitlab.example.com/oauth/authorize?client_id=APP_ID&redirect_uri=REDIRECT_URI&response_type=code&state=YOUR_UNIQUE_STATE_HASH + ``` -``` -https://gitlab.example.com/oauth/authorize?client_id=APP_ID&redirect_uri=REDIRECT_URI&response_type=code&state=YOUR_UNIQUE_STATE_HASH -``` + This will ask the user to approve the applications access to their account and + then redirect back to the `REDIRECT_URI` you provided. The redirect will + include the GET `code` parameter, for example: -This will ask the user to approve the applications access to their account and then redirect back to the `REDIRECT_URI` you provided. The redirect will -include the GET `code` parameter, for example: + ``` + http://myapp.com/oauth/redirect?code=1234567890&state=YOUR_UNIQUE_STATE_HASH + ``` -`http://myapp.com/oauth/redirect?code=1234567890&state=YOUR_UNIQUE_STATE_HASH` + You should then use `code` to request an access token. -You should then use the `code` to request an access token. +1. Once you have the authorization code you can request an `access_token` using the + code. You can do that by using any HTTP client. In the following example, + we are using Ruby's `rest-client`: -#### 2. Requesting access token + ```ruby + parameters = 'client_id=APP_ID&client_secret=APP_SECRET&code=RETURNED_CODE&grant_type=authorization_code&redirect_uri=REDIRECT_URI' + RestClient.post 'http://gitlab.example.com/oauth/token', parameters + ``` -Once you have the authorization code you can request an `access_token` using the code, to do that you can use any HTTP client. In the following example, -we are using Ruby's `rest-client`: + Example response: -``` -parameters = 'client_id=APP_ID&client_secret=APP_SECRET&code=RETURNED_CODE&grant_type=authorization_code&redirect_uri=REDIRECT_URI' -RestClient.post 'http://gitlab.example.com/oauth/token', parameters + ```json + { + "access_token": "de6780bc506a0446309bd9362820ba8aed28aa506c71eedbe1c5c4f9dd350e54", + "token_type": "bearer", + "expires_in": 7200, + "refresh_token": "8257e65c97202ed1726cf9571600918f3bffb2544b26e00a61df9897668c33a1" + } + ``` -# The response will be -{ - "access_token": "de6780bc506a0446309bd9362820ba8aed28aa506c71eedbe1c5c4f9dd350e54", - "token_type": "bearer", - "expires_in": 7200, - "refresh_token": "8257e65c97202ed1726cf9571600918f3bffb2544b26e00a61df9897668c33a1" -} -``` ->**Note:** -The `redirect_uri` must match the `redirect_uri` used in the original authorization request. +NOTE: **Note:** +The `redirect_uri` must match the `redirect_uri` used in the original +authorization request. You can now make requests to the API with the access token returned. +### Implicit grant flow -### Implicit Grant - -Check [RFC spec](http://tools.ietf.org/html/rfc6749#section-4.2) for a detailed flow description. - -Unlike the web flow, the client receives an `access token` immediately as a result of the authorization request. The flow does not use client secret -or authorization code because all of the application code and storage is easily accessible, therefore __secrets__ can leak easily. +NOTE: **Note:** +Check the [RFC spec](https://tools.ietf.org/html/rfc6749#section-4.2) for a +detailed flow description. ->**Important:** Avoid using this flow for applications that store data outside of the GitLab instance. If you do, make sure to verify `application id` -associated with access token before granting access to the data +CAUTION: **Important:** +Avoid using this flow for applications that store data outside of the GitLab +instance. If you do, make sure to verify `application id` associated with the +access token before granting access to the data (see [/oauth/token/info](https://github.com/doorkeeper-gem/doorkeeper/wiki/API-endpoint-descriptions-and-examples#get----oauthtokeninfo)). +Unlike the web flow, the client receives an `access token` immediately as a +result of the authorization request. The flow does not use the client secret +or the authorization code because all of the application code and storage is +easily accessible, therefore secrets can leak easily. -#### 1. Requesting access token - -To request the access token, you should redirect the user to the `/oauth/authorize` endpoint using `token` response type: +To request the access token, you should redirect the user to the +`/oauth/authorize` endpoint using `token` response type: ``` https://gitlab.example.com/oauth/authorize?client_id=APP_ID&redirect_uri=REDIRECT_URI&response_type=token&state=YOUR_UNIQUE_STATE_HASH ``` -This will ask the user to approve the application's access to their account and then redirect back to the `REDIRECT_URI` you provided. The redirect -will include a fragment with `access_token` as well as token details in GET parameters, for example: +This will ask the user to approve the application's access to their account and +then redirect them back to the `REDIRECT_URI` you provided. The redirect +will include a fragment with `access_token` as well as token details in GET +parameters, for example: ``` http://myapp.com/oauth/redirect#access_token=ABCDExyz123&state=YOUR_UNIQUE_STATE_HASH&token_type=bearer&expires_in=3600 ``` -### Resource Owner Password Credentials +### Resource owner password credentials flow -Check [RFC spec](http://tools.ietf.org/html/rfc6749#section-4.3) for a detailed flow description. +NOTE: **Note:** +Check the [RFC spec](https://tools.ietf.org/html/rfc6749#section-4.3) for a +detailed flow description. -> **Deprecation notice:** Starting in GitLab 8.11, the Resource Owner Password Credentials has been *disabled* for users with two-factor authentication -turned on. These users can access the API using [personal access tokens] instead. +NOTE: **Note:** +The Resource Owner Password Credentials is disabled for users with [two-factor +authentication](../user/profile/account/two_factor_authentication.md) turned on. +These users can access the API using [personal access tokens](../user/profile/personal_access_tokens.md) +instead. -In this flow, a token is requested in exchange for the resource owner credentials (username and password). -The credentials should only be used when there is a high degree of trust between the resource owner and the client (e.g. the -client is part of the device operating system or a highly privileged application), and when other authorization grant types are not -available (such as an authorization code). +In this flow, a token is requested in exchange for the resource owner credentials +(username and password). ->**Important:** -Never store the user's credentials and only use this grant type when your client is deployed to a trusted environment, in 99% of cases [personal access tokens] -are a better choice. +The credentials should only be used when: -Even though this grant type requires direct client access to the resource owner credentials, the resource owner credentials are used -for a single request and are exchanged for an access token. This grant type can eliminate the need for the client to store the -resource owner credentials for future use, by exchanging the credentials with a long-lived access token or refresh token. +- There is a high degree of trust between the resource owner and the client. For + example, the client is part of the device operating system or a highly + privileged application. +- Other authorization grant types are not available (such as an authorization code). -#### 1. Requesting access token +CAUTION: **Important:** +Never store the user's credentials and only use this grant type when your client +is deployed to a trusted environment, in 99% of cases +[personal access tokens](../user/profile/personal_access_tokens.md) are a better +choice. -POST request to `/oauth/token` with parameters: +Even though this grant type requires direct client access to the resource owner +credentials, the resource owner credentials are used for a single request and +are exchanged for an access token. This grant type can eliminate the need for +the client to store the resource owner credentials for future use, by exchanging +the credentials with a long-lived access token or refresh token. -``` +To request an access token, you must make a POST request to `/oauth/token` with +the following parameters: + +```json { "grant_type" : "password", "username" : "user@example.com", @@ -128,6 +167,13 @@ POST request to `/oauth/token` with parameters: } ``` +Example cURL request: + +```sh +echo 'grant_type=password&username=<your_username>&password=<your_password>' > auth.txt +curl --data "@auth.txt" --request POST https://gitlab.example.com/oauth/token +``` + Then, you'll receive the access token back in the response: ``` @@ -138,7 +184,7 @@ Then, you'll receive the access token back in the response: } ``` -For testing you can use the oauth2 ruby gem: +For testing, you can use the `oauth2` Ruby gem: ``` client = OAuth2::Client.new('the_client_id', 'the_client_secret', :site => "http://example.com") @@ -148,7 +194,9 @@ puts access_token.token ## Access GitLab API with `access token` -The `access token` allows you to make requests to the API on a behalf of a user. You can pass the token either as GET parameter +The `access token` allows you to make requests to the API on behalf of a user. +You can pass the token either as GET parameter: + ``` GET https://gitlab.example.com/api/v4/user?access_token=OAUTH-TOKEN ``` @@ -159,5 +207,3 @@ or you can put the token to the Authorization header: curl --header "Authorization: Bearer OAUTH-TOKEN" https://gitlab.example.com/api/v4/user ``` -[personal access tokens]: ../user/profile/personal_access_tokens.md -[CSRF attacks]: http://www.oauthsecurity.com/#user-content-authorization-code-flow diff --git a/doc/api/releases.md b/doc/api/releases/index.md index 4613fe3482a..943109a3ea9 100644 --- a/doc/api/releases.md +++ b/doc/api/releases/index.md @@ -1,7 +1,8 @@ # Releases API > - [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/41766) in GitLab 11.7. -> - Using this API you can manipulate GitLab's [Release](../user/project/releases/index.md) entries. +> - Using this API you can manipulate GitLab's [Release](../../user/project/releases/index.md) entries. +> - For manipulating links as a release asset, see [Release Links API](links.md) ## List Releases @@ -241,7 +242,7 @@ POST /projects/:id/releases | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). | | `name` | string | yes | The release name. | | `tag_name` | string | yes | The tag where the release will be created from. | -| `description` | string | no | The description of the release. You can use [markdown](../user/markdown.md). | +| `description` | string | yes | The description of the release. You can use [markdown](../user/markdown.md). | | `ref` | string | no | If `tag_name` doesn't exist, the release will be created from `ref`. It can be a commit SHA, another tag name, or a branch name. | | `assets:links`| array of hash | no | An array of assets links. | | `assets:links:name`| string | no (if `assets:links` specified, it's required) | The name of the link. | @@ -331,8 +332,8 @@ PUT /projects/:id/releases/:tag_name | Attribute | Type | Required | Description | | ------------- | -------------- | -------- | --------------------------------------- | | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). | +| `tag_name` | string | yes | The tag where the release will be created from. | | `name` | string | no | The release name. | -| `tag_name` | string | no | The tag where the release will be created from. | | `description` | string | no | The description of the release. You can use [markdown](../user/markdown.md). | Example request: diff --git a/doc/api/releases/links.md b/doc/api/releases/links.md new file mode 100644 index 00000000000..ae99f3bd8b6 --- /dev/null +++ b/doc/api/releases/links.md @@ -0,0 +1,177 @@ +# Release links API + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/41766) in GitLab 11.7. + +Using this API you can manipulate GitLab's [Release](../../user/project/releases/index.md) links. For manipulating other Release assets, see [Release API](index.md). + +## Get links + +Get assets as links from a Release. + +``` +GET /projects/:id/releases/:tag_name/assets/links +``` + +| Attribute | Type | Required | Description | +| ------------- | -------------- | -------- | --------------------------------------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). | +| `tag_name` | string | yes | The tag associated with the Release. | + +Example request: + +```sh +curl --header "PRIVATE-TOKEN: gDybLx3yrUK_HLp3qPjS" "http://localhost:3000/api/v4/projects/24/releases/v0.1/assets/links" +``` + +Example response: + +```json +[ + { + "id":2, + "name":"awesome-v0.2.msi", + "url":"http://192.168.10.15:3000/msi", + "external":true + }, + { + "id":1, + "name":"awesome-v0.2.dmg", + "url":"http://192.168.10.15:3000", + "external":true + } +] +``` + +## Get a link + +Get an asset as a link from a Release. + +``` +GET /projects/:id/releases/:tag_name/assets/links/:link_id +``` + +| Attribute | Type | Required | Description | +| ------------- | -------------- | -------- | --------------------------------------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). | +| `tag_name` | string | yes | The tag associated with the Release. | +| `link_id` | integer | yes | The id of the link. | + +Example request: + +```sh +curl --header "PRIVATE-TOKEN: gDybLx3yrUK_HLp3qPjS" "http://localhost:3000/api/v4/projects/24/releases/v0.1/assets/links/1" +``` + +Example response: + +```json +{ + "id":1, + "name":"awesome-v0.2.dmg", + "url":"http://192.168.10.15:3000", + "external":true +} +``` + +## Create a link + +Create an asset as a link from a Release. + +``` +POST /projects/:id/releases/:tag_name/assets/links +``` + +| Attribute | Type | Required | Description | +| ------------- | -------------- | -------- | --------------------------------------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). | +| `tag_name` | string | yes | The tag associated with the Release. | +| `name` | string | yes | The name of the link. | +| `url` | string | yes | The URL of the link. | + +Example request: + +```sh +curl --request POST \ + --header "PRIVATE-TOKEN: gDybLx3yrUK_HLp3qPjS" \ + --data name="awesome-v0.2.dmg" \ + --data url="http://192.168.10.15:3000" \ + "http://localhost:3000/api/v4/projects/24/releases/v0.1/assets/links" +``` + +Example response: + +```json +{ + "id":1, + "name":"awesome-v0.2.dmg", + "url":"http://192.168.10.15:3000", + "external":true +} +``` + +## Update a link + +Update an asset as a link from a Release. + +``` +PUT /projects/:id/releases/:tag_name/assets/links/:link_id +``` + +| Attribute | Type | Required | Description | +| ------------- | -------------- | -------- | --------------------------------------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). | +| `tag_name` | string | yes | The tag associated with the Release. | +| `link_id` | integer | yes | The id of the link. | +| `name` | string | no | The name of the link. | +| `url` | string | no | The URL of the link. | + +NOTE: **NOTE** +You have to specify at least one of `name` or `url` + +Example request: + +```sh +curl --request PUT --data name="new name" --header "PRIVATE-TOKEN: gDybLx3yrUK_HLp3qPjS" "http://localhost:3000/api/v4/projects/24/releases/v0.1/assets/links/1" +``` + +Example response: + +```json +{ + "id":1, + "name":"new name", + "url":"http://192.168.10.15:3000", + "external":true +} +``` + +## Delete a link + +Delete an asset as a link from a Release. + +``` +DELETE /projects/:id/releases/:tag_name/assets/links/:link_id +``` + +| Attribute | Type | Required | Description | +| ------------- | -------------- | -------- | --------------------------------------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). | +| `tag_name` | string | yes | The tag associated with the Release. | +| `link_id` | integer | yes | The id of the link. | + +Example request: + +```sh +curl --request DELETE --header "PRIVATE-TOKEN: gDybLx3yrUK_HLp3qPjS" "http://localhost:3000/api/v4/projects/24/releases/v0.1/assets/links/1" +``` + +Example response: + +```json +{ + "id":1, + "name":"new name", + "url":"http://192.168.10.15:3000", + "external":true +} +``` diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index efee2852eb8..d4f0da52e53 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -57,6 +57,7 @@ A job is defined by a list of parameters that define the job behavior. |---------------|----------|-------------| | [script](#script) | yes | Defines a shell script which is executed by Runner | | [extends](#extends) | no | Defines a configuration entry that this job is going to inherit from | +| [include](#include) | no | Defines a configuration entry that allows this job to include external YAML files | | [image](#image-and-services) | no | Use docker image, covered in [Using Docker Images](../docker/using_docker_images.md#define-image-and-services-from-gitlab-ciyml) | | [services](#image-and-services) | no | Use docker services, covered in [Using Docker Images](../docker/using_docker_images.md#define-image-and-services-from-gitlab-ciyml) | | [stage](#stage) | no | Defines a job stage (default: `test`) | diff --git a/doc/development/documentation/styleguide.md b/doc/development/documentation/styleguide.md index 721e26f2de9..15363b4750c 100644 --- a/doc/development/documentation/styleguide.md +++ b/doc/development/documentation/styleguide.md @@ -272,19 +272,19 @@ Inside the document: - For regular code blocks, always use a highlighting class corresponding to the language for better readability. Examples: - ```md - ```ruby - Ruby code - ``` - - ```js - JavaScript code - ``` - - ```md - Markdown code - ``` - ``` + ````md + ```ruby + Ruby code + ``` + + ```js + JavaScript code + ``` + + ```md + Markdown code + ``` + ```` - For a complete reference on code blocks, check the [Kramdown guide](https://about.gitlab.com/handbook/product/technical-writing/markdown-guide/#code-blocks). diff --git a/doc/integration/oauth_provider.md b/doc/integration/oauth_provider.md index acc9db15826..c02a29dffb4 100644 --- a/doc/integration/oauth_provider.md +++ b/doc/integration/oauth_provider.md @@ -3,8 +3,11 @@ This document is about using GitLab as an OAuth authentication service provider to sign in to other services. -If you want to use other OAuth authentication service providers to sign in to -GitLab, please see the [OAuth2 client documentation](../api/oauth2.md). +If you want to use: + +- Other OAuth authentication service providers to sign in to + GitLab, see the [OAuth2 client documentation](omniauth.md). +- The related API, see [Applications API](../api/applications.md). ## Introduction to OAuth @@ -28,7 +31,7 @@ GitLab supports two ways of adding a new OAuth2 application to an instance. You can either add an application as a regular user or add it in the admin area. What this means is that GitLab can actually have instance-wide and a user-wide applications. There is no difference between them except for the different -permission levels they are set (user/admin). The default callback URL is +permission levels they are set (user/admin). The default callback URL is `http://your-gitlab.example.com/users/auth/gitlab/callback` ## Adding an application through the profile diff --git a/doc/user/markdown.md b/doc/user/markdown.md index 893658290e5..f2448f240ca 100644 --- a/doc/user/markdown.md +++ b/doc/user/markdown.md @@ -300,7 +300,7 @@ You can use it to point out a <img src="https://gitlab.com/gitlab-org/gitlab-ce/ If you are new to this, don't be <img src="https://gitlab.com/gitlab-org/gitlab-ce/raw/master/app/assets/images/emoji/fearful.png" width="20px" height="20px">. You can easily join the emoji <img src="https://gitlab.com/gitlab-org/gitlab-ce/raw/master/app/assets/images/emoji/family.png" width="20px" height="20px">. All you need to do is to look up one of the supported codes. -Consult the [Emoji Cheat Sheet](https://www.emojicopy.com) for a list of all supported emoji codes. <img src="https://gitlab.com/gitlab-org/gitlab-ce/raw/master/app/assets/images/emoji/thumbsup.png" width="20px" height="20px"> +Consult the [Emoji Cheat Sheet](https://www.webfx.com/tools/emoji-cheat-sheet/) for a list of all supported emoji codes. <img src="https://gitlab.com/gitlab-org/gitlab-ce/raw/master/app/assets/images/emoji/thumbsup.png" width="20px" height="20px"> Most emoji are natively supported on macOS, Windows, iOS, Android and will fallback to image-based emoji where there is lack of support. diff --git a/doc/user/project/clusters/serverless/img/serverless-details.png b/doc/user/project/clusters/serverless/img/serverless-details.png Binary files differnew file mode 100644 index 00000000000..61e0735199a --- /dev/null +++ b/doc/user/project/clusters/serverless/img/serverless-details.png diff --git a/doc/user/project/clusters/serverless/img/serverless-page.png b/doc/user/project/clusters/serverless/img/serverless-page.png Binary files differindex 960d6e736d6..814b8532205 100644 --- a/doc/user/project/clusters/serverless/img/serverless-page.png +++ b/doc/user/project/clusters/serverless/img/serverless-page.png diff --git a/doc/user/project/clusters/serverless/index.md b/doc/user/project/clusters/serverless/index.md index ffce29f8f81..9ecb109fa89 100644 --- a/doc/user/project/clusters/serverless/index.md +++ b/doc/user/project/clusters/serverless/index.md @@ -103,7 +103,7 @@ In order to deploy functions to your Knative instance, the following files must The `gitlab-ci.yml` template creates a `Deploy` stage with a `functions` job that invokes the `tm` CLI with the required parameters. 2. `serverless.yml`: This file contains the metadata for your functions, - such as name, runtime, and environment. It must be included at the root of your repository. The following is a sample `echo` function which shows the required structure for the file. + such as name, runtime, and environment. It must be included at the root of your repository. The following is a sample `echo` function which shows the required structure for the file. You can find the relevant files for this project in the [functions example project](https://gitlab.com/knative-examples/functions). ```yaml service: my-functions @@ -127,7 +127,7 @@ In order to deploy functions to your Knative instance, the following files must ``` -The `serverless.yml` file contains three sections with distinct parameters: +The `serverless.yml` file is referencing both an `echo` directory (under `buildargs`) and an `echo` file (under `handler`) which is a reference to `echo.js` in the [repository](https://gitlab.com/knative-examples/functions). Additionally, it contains three sections with distinct parameters: ### `service` @@ -167,8 +167,8 @@ appear under **Operations > Serverless**. ![serverless page](img/serverless-page.png) -This page contains all functions available for the project, the URL for -accessing the function, and if available, the function's runtime information. +This page contains all functions available for the project, the description for +accessing the function, and, if available, the function's runtime information. The details are derived from the Knative installation inside each of the project's Kubernetes cluster. @@ -184,6 +184,12 @@ The sample function can now be triggered from any HTTP client using a simple `PO Currently, the Serverless page presents all functions available in all clusters registered for the project with Knative installed. +Clicking on the function name will provide additional details such as the +function's URL as well as runtime statistics such as the number of active pods +available to service the request based on load. + +![serverless function details](img/serverless-details.png) + ## Deploying Serverless applications > Introduced in GitLab 11.5. diff --git a/doc/user/project/index.md b/doc/user/project/index.md index d46ae31580a..e9a930d2ebe 100644 --- a/doc/user/project/index.md +++ b/doc/user/project/index.md @@ -65,7 +65,7 @@ common actions on issues or merge requests browse, and download job artifacts - [Pipeline settings](pipelines/settings.md): Set up Git strategy (choose the default way your repository is fetched from GitLab in a job), timeout (defines the maximum amount of time in minutes that a job is able run), custom path for `.gitlab-ci.yml`, test coverage parsing, pipeline's visibility, and much more - - [GKE cluster integration](clusters/index.md): Connecting your GitLab project + - [Kubernetes cluster integration](clusters/index.md): Connecting your GitLab project with Google Kubernetes Engine - [GitLab Pages](pages/index.md): Build, test, and deploy your static website with GitLab Pages diff --git a/doc/user/project/members/index.md b/doc/user/project/members/index.md index 2c2e8e2d556..2a490e3fd7f 100644 --- a/doc/user/project/members/index.md +++ b/doc/user/project/members/index.md @@ -77,7 +77,7 @@ GitLab users to the project. Once done, hit **Add users to project** and watch that there is a new member with the e-mail address we used above. From there on, you can resend the -invitation, change their access level or even delete them. +invitation, change their access level, or even delete them. ![Invite user members list](img/add_user_email_accept.png) diff --git a/doc/user/project/operations/error_tracking.md b/doc/user/project/operations/error_tracking.md new file mode 100644 index 00000000000..2b5abc7233f --- /dev/null +++ b/doc/user/project/operations/error_tracking.md @@ -0,0 +1,30 @@ +# Error Tracking + +> [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/169) in GitLab 11.7. + +Error tracking allows developers to easily discover and view the errors that their application may be generating. By surfacing error information where the code is being developed, efficiency and awareness can be increased. + +## Sentry error tracking + +[Sentry](https://sentry.io/) is an open source error tracking system. GitLab allows administrators to connect Sentry to GitLab, to allow users to view a list of Sentry errors in GitLab itself. + +### Deploying Sentry + +You may sign up to the cloud hosted https://sentry.io or deploy your own [on-premise instance](https://docs.sentry.io/server/installation/). + +### Enabling Sentry + +GitLab provides an easy way to connect Sentry to your project: + +1. Sign up to Sentry.io or [deploy your own](#deploying-sentry) Sentry instance. +1. [Find or generate](https://docs.sentry.io/api/auth/) a Sentry auth token for your Sentry project. +1. Navigate to your project’s **Settings > Operations** and provide the Sentry API URL and auth token. +1. Ensure that the 'Active' checkbox is set. +1. Click **Save changes** for the changes to take effect. +1. You can now visit **Operations > Error Tracking** in your project's sidebar to [view a list](#error-tracking-list) of Sentry errors. + +## Error Tracking List + +The Error Tracking list may be found at **Operations > Error Tracking** in your project's sidebar. + +![Error Tracking list](img/error_tracking_list.png) diff --git a/doc/user/project/operations/img/error_tracking_list.png b/doc/user/project/operations/img/error_tracking_list.png Binary files differnew file mode 100644 index 00000000000..aa0f9867fdb --- /dev/null +++ b/doc/user/project/operations/img/error_tracking_list.png diff --git a/doc/user/project/releases/index.md b/doc/user/project/releases/index.md index 890d6fbc6c7..00a4f6c6a6b 100644 --- a/doc/user/project/releases/index.md +++ b/doc/user/project/releases/index.md @@ -12,7 +12,7 @@ GitLab's **Releases** are a way to track deliverables in your project. Consider a snapshot in time of the source, build output, and other metadata or artifacts associated with a released version of your code. -At the moment, you can create Release entries via the [Releases API](../../../api/releases.md); +At the moment, you can create Release entries via the [Releases API](../../../api/releases/index.md); we recommend doing this as one of the last steps in your CI/CD release pipeline. ## Getting started with Releases @@ -51,6 +51,9 @@ A link is any URL which can point to whatever you like; documentation, built binaries, or other related materials. These can be both internal or external links from your GitLab instance. +NOTE: **NOTE** +You can manipulate links of each release entry with [Release Links API](../../../api/releases/links.md) + ## Releases list Navigate to **Project > Releases** in order to see the list of releases for a given diff --git a/doc/user/project/settings/img/general_settings.png b/doc/user/project/settings/img/general_settings.png Binary files differindex 96f5b84871f..4ff6fff5ca3 100644 --- a/doc/user/project/settings/img/general_settings.png +++ b/doc/user/project/settings/img/general_settings.png diff --git a/doc/user/project/settings/import_export.md b/doc/user/project/settings/import_export.md index 3bbfa74f4b7..89008fd15b9 100644 --- a/doc/user/project/settings/import_export.md +++ b/doc/user/project/settings/import_export.md @@ -61,7 +61,7 @@ The following items will be exported: - Project and wiki repositories - Project uploads -- Project configuration including web hooks and services +- Project configuration, including services - Issues with comments, merge requests with diffs and comments, labels, milestones, snippets, and other project entities - LFS objects diff --git a/doc/user/project/settings/index.md b/doc/user/project/settings/index.md index d6754372816..b09a3f927d1 100644 --- a/doc/user/project/settings/index.md +++ b/doc/user/project/settings/index.md @@ -14,7 +14,7 @@ functionality of a project. ### General project settings -Adjust your project's name, description, avatar, [default branch](../repository/branches/index.md#default-branch), and tags: +Adjust your project's name, description, avatar, [default branch](../repository/branches/index.md#default-branch), and topics: ![general project settings](img/general_settings.png) @@ -122,3 +122,9 @@ GitLab administrators can use the admin interface to move any project to any namespace if needed. [permissions]: ../../permissions.md##project-members-permissions + +## Operations settings + +### Error Tracking + +Configure Error Tracking to discover and view [Sentry errors within GitLab](../operations/error_tracking.md). diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index aae54fb34bc..fa6c9777824 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -235,8 +235,8 @@ module API forbidden! unless current_user.admin? end - def authorize!(action, subject = :global) - forbidden! unless can?(current_user, action, subject) + def authorize!(action, subject = :global, reason = nil) + forbidden!(reason) unless can?(current_user, action, subject) end def authorize_push_project @@ -496,7 +496,11 @@ module API def send_git_blob(repository, blob) env['api.format'] = :txt content_type 'text/plain' - header['Content-Disposition'] = content_disposition('attachment', blob.name) + header['Content-Disposition'] = content_disposition('inline', blob.name) + + # Let Workhorse examine the content and determine the better content disposition + header[Gitlab::Workhorse::DETECT_HEADER] = "true" + header(*Gitlab::Workhorse.send_git_blob(repository, blob)) end diff --git a/lib/api/project_clusters.rb b/lib/api/project_clusters.rb index 7aada260297..c96261a7b57 100644 --- a/lib/api/project_clusters.rb +++ b/lib/api/project_clusters.rb @@ -63,7 +63,7 @@ module API use :create_params_ee end post ':id/clusters/user' do - authorize! :create_cluster, user_project + authorize! :add_cluster, user_project, 'Instance does not support multiple Kubernetes clusters' user_cluster = ::Clusters::CreateService .new(current_user, create_cluster_user_params) diff --git a/lib/api/release/links.rb b/lib/api/release/links.rb index a75a320e929..e3072684ef7 100644 --- a/lib/api/release/links.rb +++ b/lib/api/release/links.rb @@ -8,8 +8,6 @@ module API RELEASE_ENDPOINT_REQUIREMETS = API::NAMESPACE_OR_PROJECT_REQUIREMENTS .merge(tag_name: API::NO_SLASH_URL_PART_REGEX) - before { error!('404 Not Found', 404) unless Feature.enabled?(:releases_page, user_project) } - params do requires :id, type: String, desc: 'The ID of a project' end diff --git a/lib/api/releases.rb b/lib/api/releases.rb index c3d4101528c..576fee51db0 100644 --- a/lib/api/releases.rb +++ b/lib/api/releases.rb @@ -7,7 +7,6 @@ module API RELEASE_ENDPOINT_REQUIREMETS = API::NAMESPACE_OR_PROJECT_REQUIREMENTS .merge(tag_name: API::NO_SLASH_URL_PART_REGEX) - before { error!('404 Not Found', 404) unless Feature.enabled?(:releases_page, user_project) } before { authorize_read_releases! } params do diff --git a/locale/gitlab.pot b/locale/gitlab.pot index aad8f969122..e7aa7e453bd 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2749,6 +2749,9 @@ msgstr "" msgid "Enable and configure Prometheus metrics." msgstr "" +msgid "Enable error tracking" +msgstr "" + msgid "Enable for this project" msgstr "" @@ -2980,6 +2983,9 @@ msgstr "" msgid "EventFilterBy|Filter by team" msgstr "" +msgid "Events" +msgstr "" + msgid "Every %{action} attempt has failed: %{job_error_message}. Please try again." msgstr "" @@ -3067,6 +3073,9 @@ msgstr "" msgid "Failed to load emoji list." msgstr "" +msgid "Failed to load errors from Sentry" +msgstr "" + msgid "Failed to remove issue from board, please try again." msgstr "" @@ -3250,6 +3259,9 @@ msgstr "" msgid "Geo" msgstr "" +msgid "Get started with error tracking" +msgstr "" + msgid "Getting started with releases" msgstr "" @@ -3956,6 +3968,9 @@ msgstr "" msgid "Last reply by" msgstr "" +msgid "Last seen" +msgstr "" + msgid "Last update" msgstr "" @@ -4345,6 +4360,9 @@ msgstr "" msgid "Modal|Close" msgstr "" +msgid "Monitor your errors by integrating with Sentry" +msgstr "" + msgid "Monitoring" msgstr "" @@ -4509,9 +4527,15 @@ msgstr "" msgid "No contributions were found" msgstr "" +msgid "No details available" +msgstr "" + msgid "No due date" msgstr "" +msgid "No errors to display" +msgstr "" + msgid "No estimate or time spent" msgstr "" @@ -4730,6 +4754,9 @@ msgstr "" msgid "Open comment type dropdown" msgstr "" +msgid "Open errors" +msgstr "" + msgid "Open in Xcode" msgstr "" @@ -6060,13 +6087,31 @@ msgstr "" msgid "Serverless" msgstr "" +msgid "ServerlessDetails|Copy URL to clipboard" +msgstr "" + +msgid "ServerlessDetails|Kubernetes Pods" +msgstr "" + +msgid "ServerlessDetails|Number of Kubernetes pods in use over time based on necessity." +msgstr "" + +msgid "ServerlessDetails|pod in use" +msgstr "" + +msgid "ServerlessDetails|pods in use" +msgstr "" + msgid "Serverless| In order to start using functions as a service, you must first install Knative on your Kubernetes cluster." msgstr "" msgid "Serverless|An error occurred while retrieving serverless components" msgstr "" -msgid "Serverless|Domain" +msgid "Serverless|Cluster Env" +msgstr "" + +msgid "Serverless|Description" msgstr "" msgid "Serverless|Function" @@ -6764,12 +6809,24 @@ msgstr "" msgid "There are no archived projects yet" msgstr "" +msgid "There are no closed issues" +msgstr "" + +msgid "There are no closed merge requests" +msgstr "" + msgid "There are no issues to show" msgstr "" msgid "There are no labels yet" msgstr "" +msgid "There are no open issues" +msgstr "" + +msgid "There are no open merge requests" +msgstr "" + msgid "There are no projects shared with this group yet" msgstr "" @@ -7194,6 +7251,12 @@ msgstr "" msgid "To import an SVN repository, check out %{svn_link}." msgstr "" +msgid "To keep this project going, create a new issue" +msgstr "" + +msgid "To keep this project going, create a new merge request" +msgstr "" + msgid "To link Sentry to GitLab, enter your Sentry URL and Auth Token." msgstr "" @@ -8035,7 +8098,7 @@ msgstr "" msgid "here" msgstr "" -msgid "http://<sentry-host>/api/0/projects/{organization_slug}/{project_slug}/issues/" +msgid "http://<sentry-host>/api/0/projects/{organization_slug}/{project_slug}/" msgstr "" msgid "https://your-bitbucket-server" diff --git a/package.json b/package.json index 8c6fdcbad06..325341f1ca7 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "d3-time": "^1.0.8", "d3-time-format": "^2.1.1", "dateformat": "^3.0.3", - "deckar01-task_list": "^2.0.0", + "deckar01-task_list": "^2.0.1", "diff": "^3.4.0", "document-register-element": "1.3.0", "dropzone": "^4.2.0", diff --git a/qa/qa/page/component/note.rb b/qa/qa/page/component/note.rb index 67d7f114786..f5add6bc9b5 100644 --- a/qa/qa/page/component/note.rb +++ b/qa/qa/page/component/note.rb @@ -32,9 +32,13 @@ module QA click_element :comment_button end - def reply_to_discussion(reply_text) + def type_reply_to_discussion(reply_text) all_elements(:discussion_reply).last.click fill_element :reply_input, reply_text + end + + def reply_to_discussion(reply_text) + type_reply_to_discussion(reply_text) click_element :reply_comment_button end diff --git a/qa/qa/page/project/settings/deploy_keys.rb b/qa/qa/page/project/settings/deploy_keys.rb index 3c8c0cbdf7c..5da8d352e74 100644 --- a/qa/qa/page/project/settings/deploy_keys.rb +++ b/qa/qa/page/project/settings/deploy_keys.rb @@ -14,8 +14,13 @@ module QA end view 'app/assets/javascripts/deploy_keys/components/key.vue' do - element :key_title, /class=".*qa-key-title.*"/ # rubocop:disable QA/ElementWithPattern - element :key_fingerprint, /class=".*qa-key-fingerprint.*"/ # rubocop:disable QA/ElementWithPattern + element :key + element :key_title + element :key_fingerprint + end + + def add_key + click_on 'Add key' end def fill_key_title(title) @@ -26,31 +31,29 @@ module QA fill_in 'deploy_key_key', with: key end - def add_key - click_on 'Add key' - end - - def key_title + def find_fingerprint(title) within_project_deploy_keys do - find_element(:key_title).text + find_element(:key, title) + .find(element_selector_css(:key_fingerprint)).text end end - def key_fingerprint + def has_key?(title, fingerprint) within_project_deploy_keys do - find_element(:key_fingerprint).text + find_element(:key, title) + .has_css?(element_selector_css(:key_fingerprint), text: fingerprint) end end - def key_titles + def key_title within_project_deploy_keys do - all_elements(:key_title) + find_element(:key_title).text end end - def key_fingerprints + def key_fingerprint within_project_deploy_keys do - all_elements(:key_fingerprint) + find_element(:key_fingerprint).text end end @@ -58,7 +61,7 @@ module QA def within_project_deploy_keys wait(reload: false) do - has_css?(element_selector_css(:project_deploy_keys)) + has_element?(:project_deploy_keys) end within_element(:project_deploy_keys) do diff --git a/qa/qa/resource/base.rb b/qa/qa/resource/base.rb index dcea144ab74..f325162d1c0 100644 --- a/qa/qa/resource/base.rb +++ b/qa/qa/resource/base.rb @@ -126,10 +126,6 @@ module QA mod end - def self.attributes_names - dynamic_attributes.instance_methods(false).sort.grep_v(/=$/) - end - class DSL def initialize(base) @base = base diff --git a/qa/qa/resource/deploy_key.rb b/qa/qa/resource/deploy_key.rb index 9ed8fb7726e..9565598efb0 100644 --- a/qa/qa/resource/deploy_key.rb +++ b/qa/qa/resource/deploy_key.rb @@ -8,11 +8,7 @@ module QA attribute :fingerprint do Page::Project::Settings::Repository.perform do |setting| setting.expand_deploy_keys do |key| - key_offset = key.key_titles.index do |key_title| - key_title.text == title - end - - key.key_fingerprints[key_offset].text + key.find_fingerprint(title) end end end diff --git a/qa/qa/resource/user.rb b/qa/qa/resource/user.rb index c26f0c84a1f..b9580d81171 100644 --- a/qa/qa/resource/user.rb +++ b/qa/qa/resource/user.rb @@ -6,9 +6,12 @@ module QA module Resource class User < Base attr_reader :unique_id - attr_writer :username, :password, :name, :email + attr_writer :username, :password attr_accessor :provider, :extern_uid + attribute :name + attribute :email + def initialize @unique_id = SecureRandom.hex(8) end @@ -22,11 +25,11 @@ module QA end def name - @name ||= username + @name ||= api_resource&.dig(:name) || username end def email - @email ||= "#{username}@example.com" + @email ||= api_resource&.dig(:email) || "#{username}@example.com" end def credentials_given? diff --git a/qa/qa/specs/features/browser_ui/3_create/repository/user_views_raw_diff_patch_requests_spec.rb b/qa/qa/specs/features/browser_ui/3_create/repository/user_views_raw_diff_patch_requests_spec.rb index 203338ddf77..3a5d89e6b83 100644 --- a/qa/qa/specs/features/browser_ui/3_create/repository/user_views_raw_diff_patch_requests_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/repository/user_views_raw_diff_patch_requests_spec.rb @@ -39,11 +39,15 @@ module QA end it 'user views raw email patch' do + user = Resource::User.fabricate_via_api! do |user| + user.username = Runtime::User.username + end + view_commit Page::Project::Commit::Show.perform(&:select_email_patches) - expect(page).to have_content('From: Administrator <admin@example.com>') + expect(page).to have_content("From: #{user.name} <#{user.email}>") expect(page).to have_content('Subject: [PATCH] Add second file') expect(page).to have_content('diff --git a/second b/second') end diff --git a/qa/qa/specs/features/browser_ui/3_create/wiki/create_edit_clone_push_wiki_spec.rb b/qa/qa/specs/features/browser_ui/3_create/wiki/create_edit_clone_push_wiki_spec.rb index 210271705d9..a7d0998d42c 100644 --- a/qa/qa/specs/features/browser_ui/3_create/wiki/create_edit_clone_push_wiki_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/wiki/create_edit_clone_push_wiki_spec.rb @@ -17,7 +17,8 @@ module QA login end - it 'user creates, edits, clones, and pushes to the wiki' do + # Failure reported: https://gitlab.com/gitlab-org/quality/nightly/issues/24 + it 'user creates, edits, clones, and pushes to the wiki', :quarantine do wiki = Resource::Wiki.fabricate! do |resource| resource.title = 'Home' resource.content = '# My First Wiki Content' diff --git a/qa/qa/specs/features/browser_ui/6_release/deploy_key/add_deploy_key_spec.rb b/qa/qa/specs/features/browser_ui/6_release/deploy_key/add_deploy_key_spec.rb index 84757f25379..6f39a755392 100644 --- a/qa/qa/specs/features/browser_ui/6_release/deploy_key/add_deploy_key_spec.rb +++ b/qa/qa/specs/features/browser_ui/6_release/deploy_key/add_deploy_key_spec.rb @@ -5,7 +5,7 @@ module QA describe 'Deploy key creation' do it 'user adds a deploy key' do Runtime::Browser.visit(:gitlab, Page::Main::Login) - Page::Main::Login.act { sign_in_using_credentials } + Page::Main::Login.perform(&:sign_in_using_credentials) key = Runtime::Key::RSA.new deploy_key_title = 'deploy key title' @@ -16,7 +16,13 @@ module QA resource.key = deploy_key_value end - expect(deploy_key.fingerprint).to eq(key.fingerprint) + expect(deploy_key.fingerprint).to eq key.fingerprint + + Page::Project::Settings::Repository.perform do |setting| + setting.expand_deploy_keys do |keys| + expect(keys).to have_key(deploy_key_title, key.fingerprint) + end + end end end end diff --git a/qa/spec/resource/base_spec.rb b/qa/spec/resource/base_spec.rb index dc9e16792d3..b8c406ae72a 100644 --- a/qa/spec/resource/base_spec.rb +++ b/qa/spec/resource/base_spec.rb @@ -138,10 +138,6 @@ describe QA::Resource::Base do describe '.attribute' do include_context 'simple resource' - it 'appends new attribute' do - expect(subject.attributes_names).to eq([:no_block, :test, :web_url]) - end - context 'when the attribute is populated via a block' do it 'returns value from the block' do result = subject.fabricate!(resource: resource) diff --git a/scripts/review_apps/review-apps.sh b/scripts/review_apps/review-apps.sh index 118a7c7f638..4e1dbff7b80 100755 --- a/scripts/review_apps/review-apps.sh +++ b/scripts/review_apps/review-apps.sh @@ -31,7 +31,9 @@ function ensure_namespace() { function install_tiller() { echo "Checking Tiller..." - helm init --upgrade + helm init \ + --upgrade \ + --replicas 2 kubectl rollout status -n "$TILLER_NAMESPACE" -w "deployment/tiller-deploy" if ! helm version --debug; then echo "Failed to init Tiller." diff --git a/spec/controllers/admin/requests_profiles_controller_spec.rb b/spec/controllers/admin/requests_profiles_controller_spec.rb new file mode 100644 index 00000000000..10850cb4603 --- /dev/null +++ b/spec/controllers/admin/requests_profiles_controller_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Admin::RequestsProfilesController do + set(:admin) { create(:admin) } + + before do + sign_in(admin) + end + + describe '#show' do + let(:basename) { "profile_#{Time.now.to_i}.html" } + let(:tmpdir) { Dir.mktmpdir('profiler-test') } + let(:test_file) { File.join(tmpdir, basename) } + let(:profile) { Gitlab::RequestProfiler::Profile.new(basename) } + let(:sample_data) do + <<~HTML + <!DOCTYPE html> + <html> + <body> + <h1>My First Heading</h1> + <p>My first paragraph.</p> + </body> + </html> + HTML + end + + before do + stub_const('Gitlab::RequestProfiler::PROFILES_DIR', tmpdir) + output = File.open(test_file, 'w') + output.write(sample_data) + output.close + end + + after do + File.unlink(test_file) + end + + it 'loads an HTML profile' do + get :show, params: { name: basename } + + expect(response).to have_gitlab_http_status(200) + expect(response.body).to eq(sample_data) + end + end +end diff --git a/spec/controllers/omniauth_callbacks_controller_spec.rb b/spec/controllers/omniauth_callbacks_controller_spec.rb index d377d69457f..59463462e5a 100644 --- a/spec/controllers/omniauth_callbacks_controller_spec.rb +++ b/spec/controllers/omniauth_callbacks_controller_spec.rb @@ -45,6 +45,40 @@ describe OmniauthCallbacksController, type: :controller do end end + context 'when a redirect fragment is provided' do + let(:provider) { :jwt } + let(:extern_uid) { 'my-uid' } + + before do + request.env['omniauth.params'] = { 'redirect_fragment' => 'L101' } + end + + context 'when a redirect url is stored' do + it 'redirects with fragment' do + post provider, session: { user_return_to: '/fake/url' } + + expect(response).to redirect_to('/fake/url#L101') + end + end + + context 'when a redirect url with a fragment is stored' do + it 'redirects with the new fragment' do + post provider, session: { user_return_to: '/fake/url#replaceme' } + + expect(response).to redirect_to('/fake/url#L101') + end + end + + context 'when no redirect url is stored' do + it 'does not redirect with the fragment' do + post provider + + expect(response.redirect?).to be true + expect(response.location).not_to include('#L101') + end + end + end + context 'strategies' do context 'github' do let(:extern_uid) { 'my-uid' } diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index df21dc7bc85..e0b6105bb94 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -1086,9 +1086,9 @@ describe Projects::IssuesController do end def import_csv - post :import_csv, namespace_id: project.namespace.to_param, - project_id: project.to_param, - file: file + post :import_csv, params: { namespace_id: project.namespace.to_param, + project_id: project.to_param, + file: file } end end diff --git a/spec/controllers/projects/releases_controller_spec.rb b/spec/controllers/projects/releases_controller_spec.rb index f170a2ab613..5b9d21d3d5b 100644 --- a/spec/controllers/projects/releases_controller_spec.rb +++ b/spec/controllers/projects/releases_controller_spec.rb @@ -6,10 +6,6 @@ describe Projects::ReleasesController do let!(:project) { create(:project, :repository, :public) } let!(:user) { create(:user) } - before do - stub_feature_flags(releases_page: true) - end - describe 'GET #index' do it 'renders a 200' do get_index @@ -43,18 +39,6 @@ describe Projects::ReleasesController do expect(response.status).to eq(404) end end - - context 'when releases_page feature flag is disabled' do - before do - stub_feature_flags(releases_page: false) - end - - it 'renders a 404' do - get_index - - expect(response.status).to eq(404) - end - end end private diff --git a/spec/controllers/projects/serverless/functions_controller_spec.rb b/spec/controllers/projects/serverless/functions_controller_spec.rb index a9759c4fbd8..87114d44bce 100644 --- a/spec/controllers/projects/serverless/functions_controller_spec.rb +++ b/spec/controllers/projects/serverless/functions_controller_spec.rb @@ -45,9 +45,45 @@ describe Projects::Serverless::FunctionsController do end end + describe 'GET #show' do + context 'invalid data' do + it 'has a bad function name' do + get :show, params: params({ format: :json, environment_id: "*", id: "foo" }) + expect(response).to have_gitlab_http_status(404) + end + end + + context 'valid data', :use_clean_rails_memory_store_caching do + before do + stub_kubeclient_service_pods + stub_reactive_cache(knative, + { + services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"], + pods: kube_knative_pods_body(cluster.project.name, namespace.namespace)["items"] + }) + end + + it 'has a valid function name' do + get :show, params: params({ format: :json, environment_id: "*", id: cluster.project.name }) + expect(response).to have_gitlab_http_status(200) + + expect(json_response).to include( + "name" => project.name, + "url" => "http://#{project.name}.#{namespace.namespace}.example.com", + "podcount" => 1 + ) + end + end + end + describe 'GET #index with data', :use_clean_rails_memory_store_caching do before do - stub_reactive_cache(knative, services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"]) + stub_kubeclient_service_pods + stub_reactive_cache(knative, + { + services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"], + pods: kube_knative_pods_body(cluster.project.name, namespace.namespace)["items"] + }) end it 'has data' do diff --git a/spec/features/dashboard/todos/todos_spec.rb b/spec/features/dashboard/todos/todos_spec.rb index 96b22a0f64b..2284ee925a0 100644 --- a/spec/features/dashboard/todos/todos_spec.rb +++ b/spec/features/dashboard/todos/todos_spec.rb @@ -332,7 +332,7 @@ describe 'Dashboard Todos' do it 'links to the pipelines for the merge request' do href = pipelines_project_merge_request_path(project, todo.target) - expect(page).to have_link "merge request #{todo.target.to_reference(full: true)}", href + expect(page).to have_link "merge request #{todo.target.to_reference(full: true)}", href: href end end end diff --git a/spec/features/groups/empty_states_spec.rb b/spec/features/groups/empty_states_spec.rb index 8f5ca781b2c..e4eb0d355d1 100644 --- a/spec/features/groups/empty_states_spec.rb +++ b/spec/features/groups/empty_states_spec.rb @@ -23,14 +23,52 @@ describe 'Group empty states' do end context "the project has #{issuable_name}s" do - before do + it 'does not display an empty state' do create(issuable, project_relation => project) visit path + expect(page).not_to have_selector('.empty-state') end - it 'does not display an empty state' do - expect(page).not_to have_selector('.empty-state') + it "displays link to create new #{issuable} when no open #{issuable} is found" do + create("closed_#{issuable}", project_relation => project) + issuable_link_fn = "project_#{issuable}s_path" + + visit public_send(issuable_link_fn, project) + + page.within(find('.empty-state')) do + expect(page).to have_content(/There are no open #{issuable.to_s.humanize.downcase}/) + expect(page).to have_selector("#new_#{issuable}_body_link") + end + end + + it 'displays link to create new issue when the current search gave no results' do + create(issuable, project_relation => project) + + issuable_link_fn = "project_#{issuable}s_path" + + visit public_send(issuable_link_fn, project, author_username: 'foo', scope: 'all', state: 'opened') + + page.within(find('.empty-state')) do + expect(page).to have_content(/Sorry, your filter produced no results/) + new_issuable_path = issuable == :issue ? 'new_project_issue_path' : 'project_new_merge_request_path' + + path = public_send(new_issuable_path, project) + + expect(page).to have_selector("#new_#{issuable}_body_link[href='#{path}']") + end + end + + it "displays conditional text when no closed #{issuable} is found" do + create(issuable, project_relation => project) + + issuable_link_fn = "project_#{issuable}s_path" + + visit public_send(issuable_link_fn, project, state: 'closed') + + page.within(find('.empty-state')) do + expect(page).to have_content(/There are no closed #{issuable.to_s.humanize.downcase}/) + end end end diff --git a/spec/features/projects/jobs_spec.rb b/spec/features/projects/jobs_spec.rb index 60f37f4b74a..aff3ebaf632 100644 --- a/spec/features/projects/jobs_spec.rb +++ b/spec/features/projects/jobs_spec.rb @@ -191,7 +191,7 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do href = new_project_issue_path(project, options) - page.within('.header-action-buttons') do + page.within('.build-sidebar') do expect(find('.js-new-issue')['href']).to include(href) end end diff --git a/spec/features/projects/settings/user_tags_project_spec.rb b/spec/features/projects/settings/user_tags_project_spec.rb index 9357215ae6f..e3f06c042b9 100644 --- a/spec/features/projects/settings/user_tags_project_spec.rb +++ b/spec/features/projects/settings/user_tags_project_spec.rb @@ -9,13 +9,13 @@ describe 'Projects > Settings > User tags a project' do visit edit_project_path(project) end - it 'sets project tags' do - fill_in 'Tags', with: 'tag1, tag2' + it 'sets project topics' do + fill_in 'Topics', with: 'topic1, topic2' page.within '.general-settings' do click_button 'Save changes' end - expect(find_field('Tags').value).to eq 'tag1, tag2' + expect(find_field('Topics').value).to eq 'topic1, topic2' end end diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb index 80f7232f282..682fae06434 100644 --- a/spec/finders/issues_finder_spec.rb +++ b/spec/finders/issues_finder_spec.rb @@ -174,9 +174,13 @@ describe IssuesFinder do context 'filtering by upcoming milestone' do let(:params) { { milestone_title: Milestone::Upcoming.name } } + let!(:group) { create(:group, :public) } + let!(:group_member) { create(:group_member, group: group, user: user) } + let(:project_no_upcoming_milestones) { create(:project, :public) } let(:project_next_1_1) { create(:project, :public) } let(:project_next_8_8) { create(:project, :public) } + let(:project_in_group) { create(:project, :public, namespace: group) } let(:yesterday) { Date.today - 1.day } let(:tomorrow) { Date.today + 1.day } @@ -187,21 +191,22 @@ describe IssuesFinder do [ create(:milestone, :closed, project: project_no_upcoming_milestones), create(:milestone, project: project_next_1_1, title: '1.1', due_date: two_days_from_now), - create(:milestone, project: project_next_1_1, title: '8.8', due_date: ten_days_from_now), - create(:milestone, project: project_next_8_8, title: '1.1', due_date: yesterday), - create(:milestone, project: project_next_8_8, title: '8.8', due_date: tomorrow) + create(:milestone, project: project_next_1_1, title: '8.9', due_date: ten_days_from_now), + create(:milestone, project: project_next_8_8, title: '1.2', due_date: yesterday), + create(:milestone, project: project_next_8_8, title: '8.8', due_date: tomorrow), + create(:milestone, group: group, title: '9.9', due_date: tomorrow) ] end before do milestones.each do |milestone| - create(:issue, project: milestone.project, milestone: milestone, author: user, assignees: [user]) + create(:issue, project: milestone.project || project_in_group, milestone: milestone, author: user, assignees: [user]) end end - it 'returns issues in the upcoming milestone for each project' do - expect(issues.map { |issue| issue.milestone.title }).to contain_exactly('1.1', '8.8') - expect(issues.map { |issue| issue.milestone.due_date }).to contain_exactly(tomorrow, two_days_from_now) + it 'returns issues in the upcoming milestone for each project or group' do + expect(issues.map { |issue| issue.milestone.title }).to contain_exactly('1.1', '8.8', '9.9') + expect(issues.map { |issue| issue.milestone.due_date }).to contain_exactly(tomorrow, two_days_from_now, tomorrow) end end diff --git a/spec/finders/projects/serverless/functions_finder_spec.rb b/spec/finders/projects/serverless/functions_finder_spec.rb index 60d02b12054..35279906854 100644 --- a/spec/finders/projects/serverless/functions_finder_spec.rb +++ b/spec/finders/projects/serverless/functions_finder_spec.rb @@ -29,15 +29,34 @@ describe Projects::Serverless::FunctionsFinder do context 'has knative installed' do let!(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) } + let(:finder) { described_class.new(project.clusters) } it 'there are no functions' do - expect(described_class.new(project.clusters).execute).to be_empty + expect(finder.execute).to be_empty end it 'there are functions', :use_clean_rails_memory_store_caching do - stub_reactive_cache(knative, services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"]) + stub_kubeclient_service_pods + stub_reactive_cache(knative, + { + services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"], + pods: kube_knative_pods_body(cluster.project.name, namespace.namespace)["items"] + }) - expect(described_class.new(project.clusters).execute).not_to be_empty + expect(finder.execute).not_to be_empty + end + + it 'has a function', :use_clean_rails_memory_store_caching do + stub_kubeclient_service_pods + stub_reactive_cache(knative, + { + services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"], + pods: kube_knative_pods_body(cluster.project.name, namespace.namespace)["items"] + }) + + result = finder.service(cluster.environment_scope, cluster.project.name) + expect(result).not_to be_empty + expect(result["metadata"]["name"]).to be_eql(cluster.project.name) end end end diff --git a/spec/helpers/projects/error_tracking_helper_spec.rb b/spec/helpers/projects/error_tracking_helper_spec.rb new file mode 100644 index 00000000000..7516a636c93 --- /dev/null +++ b/spec/helpers/projects/error_tracking_helper_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Projects::ErrorTrackingHelper do + include Gitlab::Routing.url_helpers + + set(:project) { create(:project) } + + describe '#error_tracking_data' do + let(:setting_path) { project_settings_operations_path(project) } + + let(:index_path) do + project_error_tracking_index_path(project, format: :json) + end + + context 'without error_tracking_setting' do + it 'returns frontend configuration' do + expect(error_tracking_data(project)).to eq( + 'index-path' => index_path, + 'enable-error-tracking-link' => setting_path, + 'error-tracking-enabled' => 'false', + "illustration-path" => "/images/illustrations/cluster_popover.svg" + ) + end + end + + context 'with error_tracking_setting' do + let(:error_tracking_setting) do + create(:project_error_tracking_setting, project: project) + end + + context 'when enabled' do + before do + error_tracking_setting.update!(enabled: true) + end + + it 'show error tracking enabled' do + expect(error_tracking_data(project)).to include( + 'error-tracking-enabled' => 'true' + ) + end + end + + context 'when disabled' do + before do + error_tracking_setting.update!(enabled: false) + end + + it 'show error tracking not enabled' do + expect(error_tracking_data(project)).to include( + 'error-tracking-enabled' => 'false' + ) + end + end + end + end +end diff --git a/spec/javascripts/error_tracking/components/error_tracking_list_spec.js b/spec/javascripts/error_tracking/components/error_tracking_list_spec.js new file mode 100644 index 00000000000..08bbb390993 --- /dev/null +++ b/spec/javascripts/error_tracking/components/error_tracking_list_spec.js @@ -0,0 +1,100 @@ +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import Vuex from 'vuex'; +import ErrorTrackingList from '~/error_tracking/components/error_tracking_list.vue'; +import { GlButton, GlEmptyState, GlLoadingIcon, GlTable } from '@gitlab/ui'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('ErrorTrackingList', () => { + let store; + let wrapper; + + function mountComponent({ errorTrackingEnabled = true } = {}) { + wrapper = shallowMount(ErrorTrackingList, { + localVue, + store, + propsData: { + indexPath: '/path', + enableErrorTrackingLink: '/link', + errorTrackingEnabled, + illustrationPath: 'illustration/path', + }, + }); + } + + beforeEach(() => { + const actions = { + getErrorList: () => {}, + }; + + const state = { + errors: [], + loading: true, + }; + + store = new Vuex.Store({ + actions, + state, + }); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + }); + + describe('loading', () => { + beforeEach(() => { + mountComponent(); + }); + + it('shows spinner', () => { + expect(wrapper.find(GlLoadingIcon).exists()).toBeTruthy(); + expect(wrapper.find(GlTable).exists()).toBeFalsy(); + expect(wrapper.find(GlButton).exists()).toBeFalsy(); + }); + }); + + describe('results', () => { + beforeEach(() => { + store.state.loading = false; + + mountComponent(); + }); + + it('shows table', () => { + expect(wrapper.find(GlLoadingIcon).exists()).toBeFalsy(); + expect(wrapper.find(GlTable).exists()).toBeTruthy(); + expect(wrapper.find(GlButton).exists()).toBeTruthy(); + }); + }); + + describe('no results', () => { + beforeEach(() => { + store.state.loading = false; + + mountComponent(); + }); + + it('shows empty table', () => { + expect(wrapper.find(GlLoadingIcon).exists()).toBeFalsy(); + expect(wrapper.find(GlTable).exists()).toBeTruthy(); + expect(wrapper.find(GlButton).exists()).toBeTruthy(); + }); + }); + + describe('error tracking feature disabled', () => { + beforeEach(() => { + mountComponent({ errorTrackingEnabled: false }); + }); + + it('shows empty state', () => { + expect(wrapper.find(GlEmptyState).exists()).toBeTruthy(); + expect(wrapper.find(GlLoadingIcon).exists()).toBeFalsy(); + expect(wrapper.find(GlTable).exists()).toBeFalsy(); + expect(wrapper.find(GlButton).exists()).toBeFalsy(); + }); + }); +}); diff --git a/spec/javascripts/error_tracking/store/mutation_spec.js b/spec/javascripts/error_tracking/store/mutation_spec.js new file mode 100644 index 00000000000..8117104bdbc --- /dev/null +++ b/spec/javascripts/error_tracking/store/mutation_spec.js @@ -0,0 +1,36 @@ +import mutations from '~/error_tracking/store/mutations'; +import * as types from '~/error_tracking/store/mutation_types'; + +describe('Error tracking mutations', () => { + describe('SET_ERRORS', () => { + let state; + + beforeEach(() => { + state = { errors: [] }; + }); + + it('camelizes response', () => { + const errors = [ + { + title: 'the title', + external_url: 'localhost:3456', + count: 100, + userCount: 10, + }, + ]; + + mutations[types.SET_ERRORS](state, errors); + + expect(state).toEqual({ + errors: [ + { + title: 'the title', + externalUrl: 'localhost:3456', + count: 100, + userCount: 10, + }, + ], + }); + }); + }); +}); diff --git a/spec/javascripts/fixtures/oauth_remember_me.html.haml b/spec/javascripts/fixtures/oauth_remember_me.html.haml index 7886e995e57..a5d7c4e816a 100644 --- a/spec/javascripts/fixtures/oauth_remember_me.html.haml +++ b/spec/javascripts/fixtures/oauth_remember_me.html.haml @@ -3,3 +3,4 @@ %a.oauth-login.twitter{ href: "http://example.com/" } %a.oauth-login.github{ href: "http://example.com/" } + %a.oauth-login.facebook{ href: "http://example.com/?redirect_fragment=L1" } diff --git a/spec/javascripts/fixtures/sessions.rb b/spec/javascripts/fixtures/sessions.rb new file mode 100644 index 00000000000..e90a58e8c54 --- /dev/null +++ b/spec/javascripts/fixtures/sessions.rb @@ -0,0 +1,26 @@ +require 'spec_helper' + +describe 'Sessions (JavaScript fixtures)' do + include JavaScriptFixturesHelpers + + before(:all) do + clean_frontend_fixtures('sessions/') + end + + describe SessionsController, '(JavaScript fixtures)', type: :controller do + include DeviseHelpers + + render_views + + before do + set_devise_mapping(context: @request) + end + + it 'sessions/new.html.raw' do |example| + get :new + + expect(response).to be_success + store_frontend_fixture(response, example.description) + end + end +end diff --git a/spec/javascripts/jobs/components/sidebar_spec.js b/spec/javascripts/jobs/components/sidebar_spec.js index b0bc16d7c64..3a02351460c 100644 --- a/spec/javascripts/jobs/components/sidebar_spec.js +++ b/spec/javascripts/jobs/components/sidebar_spec.js @@ -28,7 +28,7 @@ describe('Sidebar details block', () => { store, }); - expect(vm.$el.querySelector('.js-retry-job')).toBeNull(); + expect(vm.$el.querySelector('.js-retry-button')).toBeNull(); }); }); @@ -70,7 +70,7 @@ describe('Sidebar details block', () => { }); it('should render link to retry job', () => { - expect(vm.$el.querySelector('.js-retry-job').getAttribute('href')).toEqual(job.retry_path); + expect(vm.$el.querySelector('.js-retry-button').getAttribute('href')).toEqual(job.retry_path); }); it('should render link to cancel job', () => { diff --git a/spec/javascripts/jobs/store/getters_spec.js b/spec/javascripts/jobs/store/getters_spec.js index 4195d9d3680..7931b2af79f 100644 --- a/spec/javascripts/jobs/store/getters_spec.js +++ b/spec/javascripts/jobs/store/getters_spec.js @@ -8,30 +8,6 @@ describe('Job Store Getters', () => { localState = state(); }); - describe('headerActions', () => { - describe('with new issue path', () => { - it('returns an array with action to create a new issue', () => { - localState.job.new_issue_path = 'issues/new'; - - expect(getters.headerActions(localState)).toEqual([ - { - label: 'New issue', - path: localState.job.new_issue_path, - cssClass: - 'js-new-issue btn btn-success btn-inverted d-none d-md-block d-lg-block d-xl-block', - type: 'link', - }, - ]); - }); - }); - - describe('without new issue path', () => { - it('returns an empty array', () => { - expect(getters.headerActions(localState)).toEqual([]); - }); - }); - }); - describe('headerTime', () => { describe('when the job has started key', () => { it('returns started key', () => { diff --git a/spec/javascripts/lib/utils/url_utility_spec.js b/spec/javascripts/lib/utils/url_utility_spec.js index e4df8441793..381c7b2d0a6 100644 --- a/spec/javascripts/lib/utils/url_utility_spec.js +++ b/spec/javascripts/lib/utils/url_utility_spec.js @@ -1,4 +1,4 @@ -import { webIDEUrl, mergeUrlParams } from '~/lib/utils/url_utility'; +import * as urlUtils from '~/lib/utils/url_utility'; describe('URL utility', () => { describe('webIDEUrl', () => { @@ -8,7 +8,7 @@ describe('URL utility', () => { describe('without relative_url_root', () => { it('returns IDE path with route', () => { - expect(webIDEUrl('/gitlab-org/gitlab-ce/merge_requests/1')).toBe( + expect(urlUtils.webIDEUrl('/gitlab-org/gitlab-ce/merge_requests/1')).toBe( '/-/ide/project/gitlab-org/gitlab-ce/merge_requests/1', ); }); @@ -20,7 +20,7 @@ describe('URL utility', () => { }); it('returns IDE path with route', () => { - expect(webIDEUrl('/gitlab/gitlab-org/gitlab-ce/merge_requests/1')).toBe( + expect(urlUtils.webIDEUrl('/gitlab/gitlab-org/gitlab-ce/merge_requests/1')).toBe( '/gitlab/-/ide/project/gitlab-org/gitlab-ce/merge_requests/1', ); }); @@ -29,23 +29,82 @@ describe('URL utility', () => { describe('mergeUrlParams', () => { it('adds w', () => { - expect(mergeUrlParams({ w: 1 }, '#frag')).toBe('?w=1#frag'); - expect(mergeUrlParams({ w: 1 }, '/path#frag')).toBe('/path?w=1#frag'); - expect(mergeUrlParams({ w: 1 }, 'https://host/path')).toBe('https://host/path?w=1'); - expect(mergeUrlParams({ w: 1 }, 'https://host/path#frag')).toBe('https://host/path?w=1#frag'); - expect(mergeUrlParams({ w: 1 }, 'https://h/p?k1=v1#frag')).toBe('https://h/p?k1=v1&w=1#frag'); + expect(urlUtils.mergeUrlParams({ w: 1 }, '#frag')).toBe('?w=1#frag'); + expect(urlUtils.mergeUrlParams({ w: 1 }, '/path#frag')).toBe('/path?w=1#frag'); + expect(urlUtils.mergeUrlParams({ w: 1 }, 'https://host/path')).toBe('https://host/path?w=1'); + expect(urlUtils.mergeUrlParams({ w: 1 }, 'https://host/path#frag')).toBe( + 'https://host/path?w=1#frag', + ); + + expect(urlUtils.mergeUrlParams({ w: 1 }, 'https://h/p?k1=v1#frag')).toBe( + 'https://h/p?k1=v1&w=1#frag', + ); }); it('updates w', () => { - expect(mergeUrlParams({ w: 1 }, '?k1=v1&w=0#frag')).toBe('?k1=v1&w=1#frag'); + expect(urlUtils.mergeUrlParams({ w: 1 }, '?k1=v1&w=0#frag')).toBe('?k1=v1&w=1#frag'); }); it('adds multiple params', () => { - expect(mergeUrlParams({ a: 1, b: 2, c: 3 }, '#frag')).toBe('?a=1&b=2&c=3#frag'); + expect(urlUtils.mergeUrlParams({ a: 1, b: 2, c: 3 }, '#frag')).toBe('?a=1&b=2&c=3#frag'); }); it('adds and updates encoded params', () => { - expect(mergeUrlParams({ a: '&', q: '?' }, '?a=%23#frag')).toBe('?a=%26&q=%3F#frag'); + expect(urlUtils.mergeUrlParams({ a: '&', q: '?' }, '?a=%23#frag')).toBe('?a=%26&q=%3F#frag'); + }); + }); + + describe('removeParams', () => { + describe('when url is passed', () => { + it('removes query param with encoded ampersand', () => { + const url = urlUtils.removeParams(['filter'], '/mail?filter=n%3Djoe%26l%3Dhome'); + + expect(url).toBe('/mail'); + }); + + it('should remove param when url has no other params', () => { + const url = urlUtils.removeParams(['size'], '/feature/home?size=5'); + + expect(url).toBe('/feature/home'); + }); + + it('should remove param when url has other params', () => { + const url = urlUtils.removeParams(['size'], '/feature/home?q=1&size=5&f=html'); + + expect(url).toBe('/feature/home?q=1&f=html'); + }); + + it('should remove param and preserve fragment', () => { + const url = urlUtils.removeParams(['size'], '/feature/home?size=5#H2'); + + expect(url).toBe('/feature/home#H2'); + }); + + it('should remove multiple params', () => { + const url = urlUtils.removeParams(['z', 'a'], '/home?z=11111&l=en_US&a=true#H2'); + + expect(url).toBe('/home?l=en_US#H2'); + }); + }); + }); + + describe('setUrlFragment', () => { + it('should set fragment when url has no fragment', () => { + const url = urlUtils.setUrlFragment('/home/feature', 'usage'); + + expect(url).toBe('/home/feature#usage'); + }); + + it('should set fragment when url has existing fragment', () => { + const url = urlUtils.setUrlFragment('/home/feature#overview', 'usage'); + + expect(url).toBe('/home/feature#usage'); + }); + + it('should set fragment when given fragment includes #', () => { + const url = urlUtils.setUrlFragment('/home/feature#overview', '#install'); + + expect(url).toBe('/home/feature#install'); }); }); }); diff --git a/spec/javascripts/notebook/cells/output/html_spec.js b/spec/javascripts/notebook/cells/output/html_spec.js index bea62f54634..3ee404fb187 100644 --- a/spec/javascripts/notebook/cells/output/html_spec.js +++ b/spec/javascripts/notebook/cells/output/html_spec.js @@ -9,6 +9,8 @@ describe('html output cell', () => { return new Component({ propsData: { rawCode, + count: 0, + index: 0, }, }).$mount(); } diff --git a/spec/javascripts/notebook/cells/output/index_spec.js b/spec/javascripts/notebook/cells/output/index_spec.js index feab7ad4212..005569f1c2d 100644 --- a/spec/javascripts/notebook/cells/output/index_spec.js +++ b/spec/javascripts/notebook/cells/output/index_spec.js @@ -10,7 +10,7 @@ describe('Output component', () => { const createComponent = output => { vm = new Component({ propsData: { - output, + outputs: [].concat(output), count: 1, }, }); @@ -51,28 +51,21 @@ describe('Output component', () => { it('renders as an image', () => { expect(vm.$el.querySelector('img')).not.toBeNull(); }); - - it('does not render the prompt', () => { - expect(vm.$el.querySelector('.prompt span')).toBeNull(); - }); }); describe('html output', () => { - beforeEach(done => { + it('renders raw HTML', () => { createComponent(json.cells[4].outputs[0]); - setTimeout(() => { - done(); - }); - }); - - it('renders raw HTML', () => { expect(vm.$el.querySelector('p')).not.toBeNull(); - expect(vm.$el.textContent.trim()).toBe('test'); + expect(vm.$el.querySelectorAll('p').length).toBe(1); + expect(vm.$el.textContent.trim()).toContain('test'); }); - it('does not render the prompt', () => { - expect(vm.$el.querySelector('.prompt span')).toBeNull(); + it('renders multiple raw HTML outputs', () => { + createComponent([json.cells[4].outputs[0], json.cells[4].outputs[0]]); + + expect(vm.$el.querySelectorAll('p').length).toBe(2); }); }); @@ -88,10 +81,6 @@ describe('Output component', () => { it('renders as an svg', () => { expect(vm.$el.querySelector('svg')).not.toBeNull(); }); - - it('does not render the prompt', () => { - expect(vm.$el.querySelector('.prompt span')).toBeNull(); - }); }); describe('default to plain text', () => { diff --git a/spec/javascripts/notes/components/discussion_filter_spec.js b/spec/javascripts/notes/components/discussion_filter_spec.js index 5efcab436e4..91dab58ba7f 100644 --- a/spec/javascripts/notes/components/discussion_filter_spec.js +++ b/spec/javascripts/notes/components/discussion_filter_spec.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import createStore from '~/notes/stores'; import DiscussionFilter from '~/notes/components/discussion_filter.vue'; +import { DISCUSSION_FILTERS_DEFAULT_VALUE } from '~/notes/constants'; import { mountComponentWithStore } from '../../helpers/vue_mount_component_helper'; import { discussionFiltersMock, discussionMock } from '../mock_data'; @@ -20,16 +21,14 @@ describe('DiscussionFilter component', () => { }, ]; const Component = Vue.extend(DiscussionFilter); - const selectedValue = discussionFiltersMock[0].value; + const selectedValue = DISCUSSION_FILTERS_DEFAULT_VALUE; + const props = { filters: discussionFiltersMock, selectedValue }; store.state.discussions = discussions; return mountComponentWithStore(Component, { el: null, store, - props: { - filters: discussionFiltersMock, - selectedValue, - }, + props, }); }; @@ -115,4 +114,41 @@ describe('DiscussionFilter component', () => { }); }); }); + + describe('URL with Links to notes', () => { + afterEach(() => { + window.location.hash = ''; + }); + + it('updates the filter when the URL links to a note', done => { + window.location.hash = `note_${discussionMock.notes[0].id}`; + vm.currentValue = discussionFiltersMock[2].value; + vm.handleLocationHash(); + + vm.$nextTick(() => { + expect(vm.currentValue).toEqual(DISCUSSION_FILTERS_DEFAULT_VALUE); + done(); + }); + }); + + it('does not update the filter when the current filter is "Show all activity"', done => { + window.location.hash = `note_${discussionMock.notes[0].id}`; + vm.handleLocationHash(); + + vm.$nextTick(() => { + expect(vm.currentValue).toEqual(DISCUSSION_FILTERS_DEFAULT_VALUE); + done(); + }); + }); + + it('only updates filter when the URL links to a note', done => { + window.location.hash = `testing123`; + vm.handleLocationHash(); + + vm.$nextTick(() => { + expect(vm.currentValue).toEqual(DISCUSSION_FILTERS_DEFAULT_VALUE); + done(); + }); + }); + }); }); diff --git a/spec/javascripts/oauth_remember_me_spec.js b/spec/javascripts/oauth_remember_me_spec.js index 2caa266b85f..4125706a407 100644 --- a/spec/javascripts/oauth_remember_me_spec.js +++ b/spec/javascripts/oauth_remember_me_spec.js @@ -20,6 +20,10 @@ describe('OAuthRememberMe', () => { expect($('#oauth-container .oauth-login.github').attr('href')).toBe( 'http://example.com/?remember_me=1', ); + + expect($('#oauth-container .oauth-login.facebook').attr('href')).toBe( + 'http://example.com/?redirect_fragment=L1&remember_me=1', + ); }); it('removes the "remember_me" query parameter from all OAuth login buttons', () => { @@ -28,5 +32,8 @@ describe('OAuthRememberMe', () => { expect($('#oauth-container .oauth-login.twitter').attr('href')).toBe('http://example.com/'); expect($('#oauth-container .oauth-login.github').attr('href')).toBe('http://example.com/'); + expect($('#oauth-container .oauth-login.facebook').attr('href')).toBe( + 'http://example.com/?redirect_fragment=L1', + ); }); }); diff --git a/spec/javascripts/pages/sessions/new/preserve_url_fragment_spec.js b/spec/javascripts/pages/sessions/new/preserve_url_fragment_spec.js new file mode 100644 index 00000000000..7a8227479d4 --- /dev/null +++ b/spec/javascripts/pages/sessions/new/preserve_url_fragment_spec.js @@ -0,0 +1,61 @@ +import $ from 'jquery'; +import preserveUrlFragment from '~/pages/sessions/new/preserve_url_fragment'; + +describe('preserve_url_fragment', () => { + preloadFixtures('sessions/new.html.raw'); + + beforeEach(() => { + loadFixtures('sessions/new.html.raw'); + }); + + it('adds the url fragment to all login and sign up form actions', () => { + preserveUrlFragment('#L65'); + + expect($('#new_user').attr('action')).toBe('http://test.host/users/sign_in#L65'); + expect($('#new_new_user').attr('action')).toBe('http://test.host/users#L65'); + }); + + it('does not add an empty url fragment to login and sign up form actions', () => { + preserveUrlFragment(); + + expect($('#new_user').attr('action')).toBe('http://test.host/users/sign_in'); + expect($('#new_new_user').attr('action')).toBe('http://test.host/users'); + }); + + it('does not add an empty query parameter to OmniAuth login buttons', () => { + preserveUrlFragment(); + + expect($('#oauth-login-cas3').attr('href')).toBe('http://test.host/users/auth/cas3'); + + expect($('.omniauth-container #oauth-login-auth0').attr('href')).toBe( + 'http://test.host/users/auth/auth0', + ); + }); + + describe('adds "redirect_fragment" query parameter to OmniAuth login buttons', () => { + it('when "remember_me" is not present', () => { + preserveUrlFragment('#L65'); + + expect($('#oauth-login-cas3').attr('href')).toBe( + 'http://test.host/users/auth/cas3?redirect_fragment=L65', + ); + + expect($('.omniauth-container #oauth-login-auth0').attr('href')).toBe( + 'http://test.host/users/auth/auth0?redirect_fragment=L65', + ); + }); + + it('when "remember-me" is present', () => { + $('a.omniauth-btn').attr('href', (i, href) => `${href}?remember_me=1`); + preserveUrlFragment('#L65'); + + expect($('#oauth-login-cas3').attr('href')).toBe( + 'http://test.host/users/auth/cas3?remember_me=1&redirect_fragment=L65', + ); + + expect($('#oauth-login-auth0').attr('href')).toBe( + 'http://test.host/users/auth/auth0?remember_me=1&redirect_fragment=L65', + ); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/stores/get_state_key_spec.js b/spec/javascripts/vue_mr_widget/stores/get_state_key_spec.js index 61ef26cd080..b356ea85cad 100644 --- a/spec/javascripts/vue_mr_widget/stores/get_state_key_spec.js +++ b/spec/javascripts/vue_mr_widget/stores/get_state_key_spec.js @@ -76,4 +76,28 @@ describe('getStateKey', () => { expect(bound()).toEqual('archived'); }); + + it('returns rebased state key', () => { + const context = { + mergeStatus: 'checked', + mergeWhenPipelineSucceeds: false, + canMerge: true, + onlyAllowMergeIfPipelineSucceeds: true, + isPipelineFailed: true, + hasMergeableDiscussionsState: false, + isPipelineBlocked: false, + canBeMerged: false, + shouldBeRebased: true, + }; + const data = { + project_archived: false, + branch_missing: false, + commits_count: 2, + has_conflicts: false, + work_in_progress: false, + }; + const bound = getStateKey.bind(context, data); + + expect(bound()).toEqual('rebase'); + }); }); diff --git a/spec/lib/api/helpers_spec.rb b/spec/lib/api/helpers_spec.rb index 1c73a936e17..e1aea82653d 100644 --- a/spec/lib/api/helpers_spec.rb +++ b/spec/lib/api/helpers_spec.rb @@ -150,32 +150,36 @@ describe API::Helpers do end describe '#send_git_blob' do - context 'content disposition' do - let(:repository) { double } - let(:blob) { double(name: 'foobar') } + let(:repository) { double } + let(:blob) { double(name: 'foobar') } - let(:send_git_blob) do - subject.send(:send_git_blob, repository, blob) - end + let(:send_git_blob) do + subject.send(:send_git_blob, repository, blob) + end - before do - allow(subject).to receive(:env).and_return({}) - allow(subject).to receive(:content_type) - allow(subject).to receive(:header).and_return({}) - allow(Gitlab::Workhorse).to receive(:send_git_blob) - end + before do + allow(subject).to receive(:env).and_return({}) + allow(subject).to receive(:content_type) + allow(subject).to receive(:header).and_return({}) + allow(Gitlab::Workhorse).to receive(:send_git_blob) + end + + it 'sets Gitlab::Workhorse::DETECT_HEADER header' do + expect(send_git_blob[Gitlab::Workhorse::DETECT_HEADER]).to eq "true" + end + context 'content disposition' do context 'when blob name is null' do let(:blob) { double(name: nil) } it 'returns only the disposition' do - expect(send_git_blob['Content-Disposition']).to eq 'attachment' + expect(send_git_blob['Content-Disposition']).to eq 'inline' end end context 'when blob name is not null' do it 'returns disposition with the blob name' do - expect(send_git_blob['Content-Disposition']).to eq 'attachment; filename="foobar"' + expect(send_git_blob['Content-Disposition']).to eq 'inline; filename="foobar"' end end end diff --git a/spec/lib/gitlab/background_migration/backfill_legacy_project_repositories_spec.rb b/spec/lib/gitlab/background_migration/backfill_legacy_project_repositories_spec.rb index ae4b53d62e6..947c99b860f 100644 --- a/spec/lib/gitlab/background_migration/backfill_legacy_project_repositories_spec.rb +++ b/spec/lib/gitlab/background_migration/backfill_legacy_project_repositories_spec.rb @@ -2,6 +2,6 @@ require 'spec_helper' -describe Gitlab::BackgroundMigration::BackfillLegacyProjectRepositories, :migration, schema: 20181218192239 do +describe Gitlab::BackgroundMigration::BackfillLegacyProjectRepositories, :migration, schema: 20181212171634 do it_behaves_like 'backfill migration for project repositories', :legacy end diff --git a/spec/models/clusters/applications/knative_spec.rb b/spec/models/clusters/applications/knative_spec.rb index 0cf9e10ce04..35818be8deb 100644 --- a/spec/models/clusters/applications/knative_spec.rb +++ b/spec/models/clusters/applications/knative_spec.rb @@ -149,6 +149,35 @@ describe Clusters::Applications::Knative do it { is_expected.to validate_presence_of(:hostname) } end + describe '#service_pod_details' do + let(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:service) { cluster.platform_kubernetes } + let(:knative) { create(:clusters_applications_knative, cluster: cluster) } + + let(:namespace) do + create(:cluster_kubernetes_namespace, + cluster: cluster, + cluster_project: cluster.cluster_project, + project: cluster.cluster_project.project) + end + + before do + stub_kubeclient_discover(service.api_url) + stub_kubeclient_knative_services + stub_kubeclient_service_pods + stub_reactive_cache(knative, + { + services: kube_response(kube_knative_services_body), + pods: kube_response(kube_knative_pods_body(cluster.cluster_project.project.name, namespace.namespace)) + }) + synchronous_reactive_cache(knative) + end + + it 'should be able k8s core for pod details' do + expect(knative.service_pod_details(namespace.namespace, cluster.cluster_project.project.name)).not_to be_nil + end + end + describe '#services' do let(:cluster) { create(:cluster, :project, :provided_by_gcp) } let(:service) { cluster.platform_kubernetes } @@ -166,6 +195,7 @@ describe Clusters::Applications::Knative do before do stub_kubeclient_discover(service.api_url) stub_kubeclient_knative_services + stub_kubeclient_service_pods end it 'should have an unintialized cache' do @@ -174,7 +204,11 @@ describe Clusters::Applications::Knative do context 'when using synchronous reactive cache' do before do - stub_reactive_cache(knative, services: kube_response(kube_knative_services_body)) + stub_reactive_cache(knative, + { + services: kube_response(kube_knative_services_body), + pods: kube_response(kube_knative_pods_body(cluster.cluster_project.project.name, namespace.namespace)) + }) synchronous_reactive_cache(knative) end diff --git a/spec/models/concerns/manual_inverse_association_spec.rb b/spec/models/concerns/manual_inverse_association_spec.rb index aad40883854..ff4a04ea573 100644 --- a/spec/models/concerns/manual_inverse_association_spec.rb +++ b/spec/models/concerns/manual_inverse_association_spec.rb @@ -32,10 +32,10 @@ describe ManualInverseAssociation do .not_to exceed_query_limit(0) end - it 'passes arguments to the default association method, to allow reloading' do + it 'allows reloading the relation' do query_count = ActiveRecord::QueryRecorder.new do instance.manual_association - instance.manual_association(true) + instance.reload_manual_association end.count expect(query_count).to eq(2) diff --git a/spec/models/gpg_key_spec.rb b/spec/models/gpg_key_spec.rb index 33e6f1de3d1..58a1d2e4ea2 100644 --- a/spec/models/gpg_key_spec.rb +++ b/spec/models/gpg_key_spec.rb @@ -199,7 +199,7 @@ describe GpgKey do gpg_key.revoke - expect(gpg_key.subkeys(true)).to be_blank + expect(gpg_key.subkeys.reload).to be_blank end it 'invalidates all signatures associated to the subkeys' do diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index e18b29df321..bfc9035cb56 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -1418,6 +1418,23 @@ describe MergeRequest do .to change { merge_request.reload.head_pipeline } .from(nil).to(pipeline) end + + context 'when merge request has already had head pipeline' do + before do + merge_request.update!(head_pipeline: pipeline) + end + + context 'when failed to find an actual head pipeline' do + before do + allow(merge_request).to receive(:find_actual_head_pipeline) { } + end + + it 'does not update the current head pipeline' do + expect { subject } + .not_to change { merge_request.reload.head_pipeline } + end + end + end end context 'when there are no pipelines with the diff head sha' do diff --git a/spec/models/milestone_spec.rb b/spec/models/milestone_spec.rb index b3d31e65c85..015db4d4e96 100644 --- a/spec/models/milestone_spec.rb +++ b/spec/models/milestone_spec.rb @@ -240,7 +240,88 @@ describe Milestone do end end - describe '.upcoming_ids_by_projects' do + describe '#for_projects_and_groups' do + let(:project) { create(:project) } + let(:project_other) { create(:project) } + let(:group) { create(:group) } + let(:group_other) { create(:group) } + + before do + create(:milestone, project: project) + create(:milestone, project: project_other) + create(:milestone, group: group) + create(:milestone, group: group_other) + end + + subject { described_class.for_projects_and_groups(projects, groups) } + + shared_examples 'filters by projects and groups' do + it 'returns milestones filtered by project' do + milestones = described_class.for_projects_and_groups(projects, []) + + expect(milestones.count).to eq(1) + expect(milestones.first.project_id).to eq(project.id) + end + + it 'returns milestones filtered by group' do + milestones = described_class.for_projects_and_groups([], groups) + + expect(milestones.count).to eq(1) + expect(milestones.first.group_id).to eq(group.id) + end + + it 'returns milestones filtered by both project and group' do + milestones = described_class.for_projects_and_groups(projects, groups) + + expect(milestones.count).to eq(2) + expect(milestones).to contain_exactly(project.milestones.first, group.milestones.first) + end + end + + context 'ids as params' do + let(:projects) { [project.id] } + let(:groups) { [group.id] } + + it_behaves_like 'filters by projects and groups' + end + + context 'relations as params' do + let(:projects) { Project.where(id: project.id) } + let(:groups) { Group.where(id: group.id) } + + it_behaves_like 'filters by projects and groups' + end + + context 'objects as params' do + let(:projects) { [project] } + let(:groups) { [group] } + + it_behaves_like 'filters by projects and groups' + end + + it 'returns no records if projects and groups are nil' do + milestones = described_class.for_projects_and_groups(nil, nil) + + expect(milestones).to be_empty + end + end + + describe '.upcoming_ids' do + let(:group_1) { create(:group) } + let(:group_2) { create(:group) } + let(:group_3) { create(:group) } + let(:groups) { [group_1, group_2, group_3] } + + let!(:past_milestone_group_1) { create(:milestone, group: group_1, due_date: Time.now - 1.day) } + let!(:current_milestone_group_1) { create(:milestone, group: group_1, due_date: Time.now + 1.day) } + let!(:future_milestone_group_1) { create(:milestone, group: group_1, due_date: Time.now + 2.days) } + + let!(:past_milestone_group_2) { create(:milestone, group: group_2, due_date: Time.now - 1.day) } + let!(:closed_milestone_group_2) { create(:milestone, :closed, group: group_2, due_date: Time.now + 1.day) } + let!(:current_milestone_group_2) { create(:milestone, group: group_2, due_date: Time.now + 2.days) } + + let!(:past_milestone_group_3) { create(:milestone, group: group_3, due_date: Time.now - 1.day) } + let(:project_1) { create(:project) } let(:project_2) { create(:project) } let(:project_3) { create(:project) } @@ -256,16 +337,20 @@ describe Milestone do let!(:past_milestone_project_3) { create(:milestone, project: project_3, due_date: Time.now - 1.day) } - # The call to `#try` is because this returns a relation with a Postgres DB, - # and an array of IDs with a MySQL DB. - let(:milestone_ids) { described_class.upcoming_ids_by_projects(projects).map { |id| id.try(:id) || id } } + let(:milestone_ids) { described_class.upcoming_ids(projects, groups).map(&:id) } - it 'returns the next upcoming open milestone ID for each project' do - expect(milestone_ids).to contain_exactly(current_milestone_project_1.id, current_milestone_project_2.id) + it 'returns the next upcoming open milestone ID for each project and group' do + expect(milestone_ids).to contain_exactly( + current_milestone_project_1.id, + current_milestone_project_2.id, + current_milestone_group_1.id, + current_milestone_group_2.id + ) end - context 'when the projects have no open upcoming milestones' do + context 'when the projects and groups have no open upcoming milestones' do let(:projects) { [project_3] } + let(:groups) { [group_3] } it 'returns no results' do expect(milestone_ids).to be_empty diff --git a/spec/models/project_services/teamcity_service_spec.rb b/spec/models/project_services/teamcity_service_spec.rb index 43a0ed99296..64b4efca43a 100644 --- a/spec/models/project_services/teamcity_service_spec.rb +++ b/spec/models/project_services/teamcity_service_spec.rb @@ -205,7 +205,7 @@ describe TeamcityService, :use_clean_rails_memory_store_caching do end def stub_request(status: 200, body: nil, build_status: 'success') - teamcity_full_url = 'http://gitlab.com/teamcity/httpAuth/app/rest/builds/branch:unspecified:any,number:123' + teamcity_full_url = 'http://gitlab.com/teamcity/httpAuth/app/rest/builds/branch:unspecified:any,revision:123' auth = %w(mic password) body ||= %Q({"build":{"status":"#{build_status}","id":"666"}}) diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 3044150bca8..397b4d7c61f 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -1413,6 +1413,24 @@ describe Project do end end + describe '#visibility_level' do + let(:project) { build(:project) } + + subject { project.visibility_level } + + context 'by default' do + it { is_expected.to eq(Gitlab::VisibilityLevel::PRIVATE) } + end + + context 'when set to INTERNAL in application settings' do + before do + stub_application_setting(default_project_visibility: Gitlab::VisibilityLevel::INTERNAL) + end + + it { is_expected.to eq(Gitlab::VisibilityLevel::INTERNAL) } + end + end + describe '#visibility_level_allowed?' do let(:project) { create(:project, :internal) } @@ -2026,29 +2044,6 @@ describe Project do end end - describe '#get_build' do - let(:project) { create(:project, :repository) } - let(:ci_pipeline) { create(:ci_pipeline, project: project) } - - context 'when build exists' do - context 'build is associated with project' do - let(:build) { create(:ci_build, :success, pipeline: ci_pipeline) } - - it { expect(project.get_build(build.id)).to eq(build) } - end - - context 'build is not associated with project' do - let(:build) { create(:ci_build, :success) } - - it { expect(project.get_build(build.id)).to be_nil } - end - end - - context 'build does not exists' do - it { expect(project.get_build(rand 100)).to be_nil } - end - end - describe '#import_status' do context 'with import_state' do it 'returns the right status' do @@ -4425,6 +4420,17 @@ describe Project do end end + describe '#leave_pool_repository' do + let(:pool) { create(:pool_repository) } + let(:project) { create(:project, :repository, pool_repository: pool) } + + it 'removes the membership' do + project.leave_pool_repository + + expect(pool.member_projects.reload).not_to include(project) + end + end + def rugged_config rugged_repo(project.repository).config end diff --git a/spec/requests/api/files_spec.rb b/spec/requests/api/files_spec.rb index a0aee937185..9b32dc78274 100644 --- a/spec/requests/api/files_spec.rb +++ b/spec/requests/api/files_spec.rb @@ -183,14 +183,15 @@ describe API::Files do get api(url, current_user), params: params expect(response).to have_gitlab_http_status(200) + expect(headers[Gitlab::Workhorse::DETECT_HEADER]).to eq "true" end - it 'forces attachment content disposition' do + it 'sets inline content disposition by default' do url = route(file_path) + "/raw" get api(url, current_user), params: params - expect(headers['Content-Disposition']).to eq('attachment; filename="popen.rb"') + expect(headers['Content-Disposition']).to eq('inline; filename="popen.rb"') end context 'when mandatory params are not given' do diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index 0fd465da4f8..ba7930f6c9d 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -92,12 +92,10 @@ describe API::Issues do end context "when authenticated" do - let(:first_issue) { json_response.first } - it "returns an array of issues" do get api("/issues", user) - expect_paginated_array_response(size: 2) + expect_paginated_array_response([issue.id, closed_issue.id]) expect(json_response.first['title']).to eq(issue.title) expect(json_response.last).to have_key('web_url') end @@ -105,23 +103,19 @@ describe API::Issues do it 'returns an array of closed issues' do get api('/issues', user), params: { state: :closed } - expect_paginated_array_response(size: 1) - expect(first_issue['id']).to eq(closed_issue.id) + expect_paginated_array_response(closed_issue.id) end it 'returns an array of opened issues' do get api('/issues', user), params: { state: :opened } - expect_paginated_array_response(size: 1) - expect(first_issue['id']).to eq(issue.id) + expect_paginated_array_response(issue.id) end it 'returns an array of all issues' do get api('/issues', user), params: { state: :all } - expect_paginated_array_response(size: 2) - expect(first_issue['id']).to eq(issue.id) - expect(json_response.second['id']).to eq(closed_issue.id) + expect_paginated_array_response([issue.id, closed_issue.id]) end it 'returns issues assigned to me' do @@ -129,8 +123,7 @@ describe API::Issues do get api('/issues', user2), params: { scope: 'assigned_to_me' } - expect_paginated_array_response(size: 1) - expect(first_issue['id']).to eq(issue2.id) + expect_paginated_array_response(issue2.id) end it 'returns issues assigned to me (kebab-case)' do @@ -138,8 +131,7 @@ describe API::Issues do get api('/issues', user2), params: { scope: 'assigned-to-me' } - expect_paginated_array_response(size: 1) - expect(first_issue['id']).to eq(issue2.id) + expect_paginated_array_response(issue2.id) end it 'returns issues authored by the given author id' do @@ -147,8 +139,7 @@ describe API::Issues do get api('/issues', user), params: { author_id: user2.id, scope: 'all' } - expect_paginated_array_response(size: 1) - expect(first_issue['id']).to eq(issue2.id) + expect_paginated_array_response(issue2.id) end it 'returns issues assigned to the given assignee id' do @@ -156,8 +147,7 @@ describe API::Issues do get api('/issues', user), params: { assignee_id: user2.id, scope: 'all' } - expect_paginated_array_response(size: 1) - expect(first_issue['id']).to eq(issue2.id) + expect_paginated_array_response(issue2.id) end it 'returns issues authored by the given author id and assigned to the given assignee id' do @@ -165,8 +155,7 @@ describe API::Issues do get api('/issues', user), params: { author_id: user2.id, assignee_id: user2.id, scope: 'all' } - expect_paginated_array_response(size: 1) - expect(first_issue['id']).to eq(issue2.id) + expect_paginated_array_response(issue2.id) end it 'returns issues with no assignee' do @@ -174,8 +163,7 @@ describe API::Issues do get api('/issues', user), params: { assignee_id: 0, scope: 'all' } - expect_paginated_array_response(size: 1) - expect(first_issue['id']).to eq(issue2.id) + expect_paginated_array_response(issue2.id) end it 'returns issues with no assignee' do @@ -183,8 +171,7 @@ describe API::Issues do get api('/issues', user), params: { assignee_id: 'None', scope: 'all' } - expect_paginated_array_response(size: 1) - expect(first_issue['id']).to eq(issue2.id) + expect_paginated_array_response(issue2.id) end it 'returns issues with any assignee' do @@ -193,18 +180,17 @@ describe API::Issues do get api('/issues', user), params: { assignee_id: 'Any', scope: 'all' } - expect_paginated_array_response(size: 3) + expect_paginated_array_response([issue.id, confidential_issue.id, closed_issue.id]) end it 'returns issues reacted by the authenticated user' do issue2 = create(:issue, project: project, author: user, assignees: [user]) create(:award_emoji, awardable: issue2, user: user2, name: 'star') - create(:award_emoji, awardable: issue, user: user2, name: 'thumbsup') get api('/issues', user2), params: { my_reaction_emoji: 'Any', scope: 'all' } - expect_paginated_array_response(size: 2) + expect_paginated_array_response([issue2.id, issue.id]) end it 'returns issues not reacted by the authenticated user' do @@ -213,21 +199,19 @@ describe API::Issues do get api('/issues', user2), params: { my_reaction_emoji: 'None', scope: 'all' } - expect_paginated_array_response(size: 2) + expect_paginated_array_response([issue.id, closed_issue.id]) end it 'returns issues matching given search string for title' do get api("/issues", user), params: { search: issue.title } - expect_paginated_array_response(size: 1) - expect(json_response.first['id']).to eq(issue.id) + expect_paginated_array_response(issue.id) end it 'returns issues matching given search string for description' do get api("/issues", user), params: { search: issue.description } - expect_paginated_array_response(size: 1) - expect(first_issue['id']).to eq(issue.id) + expect_paginated_array_response(issue.id) end context 'filtering before a specific date' do @@ -236,15 +220,13 @@ describe API::Issues do it 'returns issues created before a specific date' do get api('/issues?created_before=2000-01-02T00:00:00.060Z', user) - expect(json_response.size).to eq(1) - expect(first_issue['id']).to eq(issue2.id) + expect_paginated_array_response(issue2.id) end it 'returns issues updated before a specific date' do get api('/issues?updated_before=2000-01-02T00:00:00.060Z', user) - expect(json_response.size).to eq(1) - expect(first_issue['id']).to eq(issue2.id) + expect_paginated_array_response(issue2.id) end end @@ -254,23 +236,21 @@ describe API::Issues do it 'returns issues created after a specific date' do get api("/issues?created_after=#{issue2.created_at}", user) - expect(json_response.size).to eq(1) - expect(first_issue['id']).to eq(issue2.id) + expect_paginated_array_response(issue2.id) end it 'returns issues updated after a specific date' do get api("/issues?updated_after=#{issue2.updated_at}", user) - expect(json_response.size).to eq(1) - expect(first_issue['id']).to eq(issue2.id) + expect_paginated_array_response(issue2.id) end end it 'returns an array of labeled issues' do get api("/issues", user), params: { labels: label.title } - expect_paginated_array_response(size: 1) - expect(first_issue['labels']).to eq([label.title]) + expect_paginated_array_response(issue.id) + expect(json_response.first['labels']).to eq([label.title]) end it 'returns an array of labeled issues when all labels matches' do @@ -282,20 +262,20 @@ describe API::Issues do get api("/issues", user), params: { labels: "#{label.title},#{label_b.title},#{label_c.title}" } - expect_paginated_array_response(size: 1) + expect_paginated_array_response(issue.id) expect(json_response.first['labels']).to eq([label_c.title, label_b.title, label.title]) end it 'returns an empty array if no issue matches labels' do get api('/issues', user), params: { labels: 'foo,bar' } - expect_paginated_array_response(size: 0) + expect_paginated_array_response([]) end it 'returns an array of labeled issues matching given state' do get api("/issues", user), params: { labels: label.title, state: :opened } - expect_paginated_array_response(size: 1) + expect_paginated_array_response(issue.id) expect(json_response.first['labels']).to eq([label.title]) expect(json_response.first['state']).to eq('opened') end @@ -303,112 +283,96 @@ describe API::Issues do it 'returns an empty array if no issue matches labels and state filters' do get api("/issues", user), params: { labels: label.title, state: :closed } - expect_paginated_array_response(size: 0) + expect_paginated_array_response([]) end it 'returns an array of issues with any label' do get api("/issues", user), params: { labels: IssuesFinder::FILTER_ANY } - expect_paginated_array_response(size: 1) - expect(json_response.first['id']).to eq(issue.id) + expect_paginated_array_response(issue.id) end it 'returns an array of issues with no label' do get api("/issues", user), params: { labels: IssuesFinder::FILTER_NONE } - expect_paginated_array_response(size: 1) - expect(json_response.first['id']).to eq(closed_issue.id) + expect_paginated_array_response(closed_issue.id) end it 'returns an array of issues with no label when using the legacy No+Label filter' do get api("/issues", user), params: { labels: "No Label" } - expect_paginated_array_response(size: 1) - expect(json_response.first['id']).to eq(closed_issue.id) + expect_paginated_array_response(closed_issue.id) end it 'returns an empty array if no issue matches milestone' do get api("/issues?milestone=#{empty_milestone.title}", user) - expect_paginated_array_response(size: 0) + expect_paginated_array_response([]) end it 'returns an empty array if milestone does not exist' do get api("/issues?milestone=foo", user) - expect_paginated_array_response(size: 0) + expect_paginated_array_response([]) end it 'returns an array of issues in given milestone' do get api("/issues?milestone=#{milestone.title}", user) - expect_paginated_array_response(size: 2) - expect(json_response.first['id']).to eq(issue.id) - expect(json_response.second['id']).to eq(closed_issue.id) + expect_paginated_array_response([issue.id, closed_issue.id]) end it 'returns an array of issues matching state in milestone' do get api("/issues?milestone=#{milestone.title}"\ '&state=closed', user) - expect_paginated_array_response(size: 1) - expect(json_response.first['id']).to eq(closed_issue.id) + expect_paginated_array_response(closed_issue.id) end it 'returns an array of issues with no milestone' do get api("/issues?milestone=#{no_milestone_title}", author) - expect_paginated_array_response(size: 1) - expect(json_response.first['id']).to eq(confidential_issue.id) + expect_paginated_array_response(confidential_issue.id) end it 'returns an array of issues found by iids' do get api('/issues', user), params: { iids: [closed_issue.iid] } - expect_paginated_array_response(size: 1) - expect(json_response.first['id']).to eq(closed_issue.id) + expect_paginated_array_response(closed_issue.id) end it 'returns an empty array if iid does not exist' do get api("/issues", user), params: { iids: [99999] } - expect_paginated_array_response(size: 0) + expect_paginated_array_response([]) end it 'sorts by created_at descending by default' do get api('/issues', user) - response_dates = json_response.map { |issue| issue['created_at'] } - - expect_paginated_array_response(size: 2) - expect(response_dates).to eq(response_dates.sort.reverse) + expect_paginated_array_response([issue.id, closed_issue.id]) end it 'sorts ascending when requested' do get api('/issues?sort=asc', user) - response_dates = json_response.map { |issue| issue['created_at'] } - - expect_paginated_array_response(size: 2) - expect(response_dates).to eq(response_dates.sort) + expect_paginated_array_response([closed_issue.id, issue.id]) end it 'sorts by updated_at descending when requested' do get api('/issues?order_by=updated_at', user) - response_dates = json_response.map { |issue| issue['updated_at'] } + issue.touch(:updated_at) - expect_paginated_array_response(size: 2) - expect(response_dates).to eq(response_dates.sort.reverse) + expect_paginated_array_response([issue.id, closed_issue.id]) end it 'sorts by updated_at ascending when requested' do get api('/issues?order_by=updated_at&sort=asc', user) - response_dates = json_response.map { |issue| issue['updated_at'] } + issue.touch(:updated_at) - expect_paginated_array_response(size: 2) - expect(response_dates).to eq(response_dates.sort) + expect_paginated_array_response([closed_issue.id, issue.id]) end it 'matches V4 response schema' do @@ -430,7 +394,8 @@ describe API::Issues do project: group_project, state: :closed, milestone: group_milestone, - updated_at: 3.hours.ago + updated_at: 3.hours.ago, + created_at: 1.day.ago end let!(:group_confidential_issue) do create :issue, @@ -438,7 +403,8 @@ describe API::Issues do project: group_project, author: author, assignees: [assignee], - updated_at: 2.hours.ago + updated_at: 2.hours.ago, + created_at: 2.days.ago end let!(:group_issue) do create :issue, @@ -448,7 +414,8 @@ describe API::Issues do milestone: group_milestone, updated_at: 1.hour.ago, title: issue_title, - description: issue_description + description: issue_description, + created_at: 5.days.ago end let!(:group_label) do create(:label, title: 'group_lbl', color: '#FFAABB', project: group_project) @@ -479,10 +446,7 @@ describe API::Issues do it 'also returns subgroups projects issues' do get api(base_url, user) - issue_ids = json_response.map { |issue| issue['id'] } - - expect_paginated_array_response(size: 5) - expect(issue_ids).to include(issue_1.id, issue_2.id) + expect_paginated_array_response([issue_2.id, issue_1.id, group_closed_issue.id, group_confidential_issue.id, group_issue.id]) end end @@ -490,7 +454,7 @@ describe API::Issues do it 'lists all issues in public projects' do get api(base_url) - expect_paginated_array_response(size: 2) + expect_paginated_array_response([group_closed_issue.id, group_issue.id]) end end @@ -502,65 +466,62 @@ describe API::Issues do it 'returns all group issues (including opened and closed)' do get api(base_url, admin) - expect_paginated_array_response(size: 3) + expect_paginated_array_response([group_closed_issue.id, group_confidential_issue.id, group_issue.id]) end it 'returns group issues without confidential issues for non project members' do get api(base_url, non_member), params: { state: :opened } - expect_paginated_array_response(size: 1) - expect(json_response.first['title']).to eq(group_issue.title) + expect_paginated_array_response(group_issue.id) end it 'returns group confidential issues for author' do get api(base_url, author), params: { state: :opened } - expect_paginated_array_response(size: 2) + expect_paginated_array_response([group_confidential_issue.id, group_issue.id]) end it 'returns group confidential issues for assignee' do get api(base_url, assignee), params: { state: :opened } - expect_paginated_array_response(size: 2) + expect_paginated_array_response([group_confidential_issue.id, group_issue.id]) end it 'returns group issues with confidential issues for project members' do get api(base_url, user), params: { state: :opened } - expect_paginated_array_response(size: 2) + expect_paginated_array_response([group_confidential_issue.id, group_issue.id]) end it 'returns group confidential issues for admin' do get api(base_url, admin), params: { state: :opened } - expect_paginated_array_response(size: 2) + expect_paginated_array_response([group_confidential_issue.id, group_issue.id]) end it 'returns an array of labeled group issues' do get api(base_url, user), params: { labels: group_label.title } - expect_paginated_array_response(size: 1) + expect_paginated_array_response(group_issue.id) expect(json_response.first['labels']).to eq([group_label.title]) end it 'returns an array of labeled group issues where all labels match' do get api(base_url, user), params: { labels: "#{group_label.title},foo,bar" } - expect_paginated_array_response(size: 0) + expect_paginated_array_response([]) end it 'returns issues matching given search string for title' do get api(base_url, user), params: { search: group_issue.title } - expect_paginated_array_response(size: 1) - expect(json_response.first['id']).to eq(group_issue.id) + expect_paginated_array_response(group_issue.id) end it 'returns issues matching given search string for description' do get api(base_url, user), params: { search: group_issue.description } - expect_paginated_array_response(size: 1) - expect(json_response.first['id']).to eq(group_issue.id) + expect_paginated_array_response(group_issue.id) end it 'returns an array of labeled issues when all labels matches' do @@ -572,69 +533,64 @@ describe API::Issues do get api(base_url, user), params: { labels: "#{group_label.title},#{label_b.title},#{label_c.title}" } - expect_paginated_array_response(size: 1) + expect_paginated_array_response(group_issue.id) expect(json_response.first['labels']).to eq([label_c.title, label_b.title, group_label.title]) end it 'returns an array of issues found by iids' do get api(base_url, user), params: { iids: [group_issue.iid] } - expect_paginated_array_response(size: 1) + expect_paginated_array_response(group_issue.id) expect(json_response.first['id']).to eq(group_issue.id) end it 'returns an empty array if iid does not exist' do get api(base_url, user), params: { iids: [99999] } - expect_paginated_array_response(size: 0) + expect_paginated_array_response([]) end it 'returns an empty array if no group issue matches labels' do get api(base_url, user), params: { labels: 'foo,bar' } - expect_paginated_array_response(size: 0) + expect_paginated_array_response([]) end it 'returns an array of group issues with any label' do get api(base_url, user), params: { labels: IssuesFinder::FILTER_ANY } - expect_paginated_array_response(size: 1) + expect_paginated_array_response(group_issue.id) expect(json_response.first['id']).to eq(group_issue.id) end it 'returns an array of group issues with no label' do get api(base_url, user), params: { labels: IssuesFinder::FILTER_NONE } - response_ids = json_response.map { |issue| issue['id'] } - - expect_paginated_array_response(size: 2) - expect(response_ids).to contain_exactly(group_closed_issue.id, group_confidential_issue.id) + expect_paginated_array_response([group_closed_issue.id, group_confidential_issue.id]) end it 'returns an empty array if no issue matches milestone' do get api(base_url, user), params: { milestone: group_empty_milestone.title } - expect_paginated_array_response(size: 0) + expect_paginated_array_response([]) end it 'returns an empty array if milestone does not exist' do get api(base_url, user), params: { milestone: 'foo' } - expect_paginated_array_response(size: 0) + expect_paginated_array_response([]) end it 'returns an array of issues in given milestone' do get api(base_url, user), params: { state: :opened, milestone: group_milestone.title } - expect_paginated_array_response(size: 1) - expect(json_response.first['id']).to eq(group_issue.id) + expect_paginated_array_response(group_issue.id) end it 'returns an array of issues matching state in milestone' do get api(base_url, user), params: { milestone: group_milestone.title, state: :closed } - expect_paginated_array_response(size: 1) - expect(json_response.first['id']).to eq(group_closed_issue.id) + expect_paginated_array_response(group_closed_issue.id) end it 'returns an array of issues with no milestone' do @@ -642,44 +598,33 @@ describe API::Issues do expect(response).to have_gitlab_http_status(200) - expect_paginated_array_response(size: 1) - expect(json_response.first['id']).to eq(group_confidential_issue.id) + expect_paginated_array_response(group_confidential_issue.id) end it 'sorts by created_at descending by default' do get api(base_url, user) - response_dates = json_response.map { |issue| issue['created_at'] } - - expect_paginated_array_response(size: 3) - expect(response_dates).to eq(response_dates.sort.reverse) + expect_paginated_array_response([group_closed_issue.id, group_confidential_issue.id, group_issue.id]) end it 'sorts ascending when requested' do get api("#{base_url}?sort=asc", user) - response_dates = json_response.map { |issue| issue['created_at'] } - - expect_paginated_array_response(size: 3) - expect(response_dates).to eq(response_dates.sort) + expect_paginated_array_response([group_issue.id, group_confidential_issue.id, group_closed_issue.id]) end it 'sorts by updated_at descending when requested' do get api("#{base_url}?order_by=updated_at", user) - response_dates = json_response.map { |issue| issue['updated_at'] } + group_issue.touch(:updated_at) - expect_paginated_array_response(size: 3) - expect(response_dates).to eq(response_dates.sort.reverse) + expect_paginated_array_response([group_issue.id, group_confidential_issue.id, group_closed_issue.id]) end it 'sorts by updated_at ascending when requested' do get api(base_url, user), params: { order_by: :updated_at, sort: :asc } - response_dates = json_response.map { |issue| issue['updated_at'] } - - expect_paginated_array_response(size: 3) - expect(response_dates).to eq(response_dates.sort) + expect_paginated_array_response([group_closed_issue.id, group_confidential_issue.id, group_issue.id]) end end end @@ -691,8 +636,7 @@ describe API::Issues do it 'returns public project issues' do get api("/projects/#{project.id}/issues") - expect_paginated_array_response(size: 2) - expect(json_response.first['title']).to eq(issue.title) + expect_paginated_array_response([issue.id, closed_issue.id]) end end @@ -731,56 +675,49 @@ describe API::Issues do get api("/projects/#{restricted_project.id}/issues", non_member) - expect_paginated_array_response(size: 0) + expect_paginated_array_response([]) end it 'returns project issues without confidential issues for non project members' do get api("#{base_url}/issues", non_member) - expect_paginated_array_response(size: 2) - expect(json_response.first['title']).to eq(issue.title) + expect_paginated_array_response([issue.id, closed_issue.id]) end it 'returns project issues without confidential issues for project members with guest role' do get api("#{base_url}/issues", guest) - expect_paginated_array_response(size: 2) - expect(json_response.first['title']).to eq(issue.title) + expect_paginated_array_response([issue.id, closed_issue.id]) end it 'returns project confidential issues for author' do get api("#{base_url}/issues", author) - expect_paginated_array_response(size: 3) - expect(json_response.first['title']).to eq(issue.title) + expect_paginated_array_response([issue.id, confidential_issue.id, closed_issue.id]) end it 'returns project confidential issues for assignee' do get api("#{base_url}/issues", assignee) - expect_paginated_array_response(size: 3) - expect(json_response.first['title']).to eq(issue.title) + expect_paginated_array_response([issue.id, confidential_issue.id, closed_issue.id]) end it 'returns project issues with confidential issues for project members' do get api("#{base_url}/issues", user) - expect_paginated_array_response(size: 3) - expect(json_response.first['title']).to eq(issue.title) + expect_paginated_array_response([issue.id, confidential_issue.id, closed_issue.id]) end it 'returns project confidential issues for admin' do get api("#{base_url}/issues", admin) - expect_paginated_array_response(size: 3) - expect(json_response.first['title']).to eq(issue.title) + expect_paginated_array_response([issue.id, confidential_issue.id, closed_issue.id]) end it 'returns an array of labeled project issues' do get api("#{base_url}/issues", user), params: { labels: label.title } - expect_paginated_array_response(size: 1) - expect(json_response.first['labels']).to eq([label.title]) + expect_paginated_array_response(issue.id) end it 'returns an array of labeled issues when all labels matches' do @@ -792,142 +729,117 @@ describe API::Issues do get api("#{base_url}/issues", user), params: { labels: "#{label.title},#{label_b.title},#{label_c.title}" } - expect_paginated_array_response(size: 1) - expect(json_response.first['labels']).to eq([label_c.title, label_b.title, label.title]) + expect_paginated_array_response(issue.id) end it 'returns issues matching given search string for title' do get api("#{base_url}/issues?search=#{issue.title}", user) - expect_paginated_array_response(size: 1) - expect(json_response.first['id']).to eq(issue.id) + expect_paginated_array_response(issue.id) end it 'returns issues matching given search string for description' do get api("#{base_url}/issues?search=#{issue.description}", user) - expect_paginated_array_response(size: 1) - expect(json_response.first['id']).to eq(issue.id) + expect_paginated_array_response(issue.id) end it 'returns an array of issues found by iids' do get api("#{base_url}/issues", user), params: { iids: [issue.iid] } - expect_paginated_array_response(size: 1) - expect(json_response.first['id']).to eq(issue.id) + expect_paginated_array_response(issue.id) end it 'returns an empty array if iid does not exist' do get api("#{base_url}/issues", user), params: { iids: [99999] } - expect_paginated_array_response(size: 0) + expect_paginated_array_response([]) end it 'returns an empty array if not all labels matches' do get api("#{base_url}/issues?labels=#{label.title},foo", user) - expect_paginated_array_response(size: 0) + expect_paginated_array_response([]) end it 'returns an array of project issues with any label' do get api("#{base_url}/issues", user), params: { labels: IssuesFinder::FILTER_ANY } - expect_paginated_array_response(size: 1) - expect(json_response.first['id']).to eq(issue.id) + expect_paginated_array_response(issue.id) end it 'returns an array of project issues with no label' do get api("#{base_url}/issues", user), params: { labels: IssuesFinder::FILTER_NONE } - response_ids = json_response.map { |issue| issue['id'] } - - expect_paginated_array_response(size: 2) - expect(response_ids).to contain_exactly(closed_issue.id, confidential_issue.id) + expect_paginated_array_response([confidential_issue.id, closed_issue.id]) end it 'returns an empty array if no project issue matches labels' do get api("#{base_url}/issues", user), params: { labels: 'foo,bar' } - expect_paginated_array_response(size: 0) + expect_paginated_array_response([]) end it 'returns an empty array if no issue matches milestone' do get api("#{base_url}/issues", user), params: { milestone: empty_milestone.title } - expect_paginated_array_response(size: 0) + expect_paginated_array_response([]) end it 'returns an empty array if milestone does not exist' do get api("#{base_url}/issues", user), params: { milestone: :foo } - expect_paginated_array_response(size: 0) + expect_paginated_array_response([]) end it 'returns an array of issues in given milestone' do get api("#{base_url}/issues", user), params: { milestone: milestone.title } - expect_paginated_array_response(size: 2) - expect(json_response.first['id']).to eq(issue.id) - expect(json_response.second['id']).to eq(closed_issue.id) + expect_paginated_array_response([issue.id, closed_issue.id]) end it 'returns an array of issues matching state in milestone' do get api("#{base_url}/issues", user), params: { milestone: milestone.title, state: :closed } - expect_paginated_array_response(size: 1) - expect(json_response.first['id']).to eq(closed_issue.id) + expect_paginated_array_response(closed_issue.id) end it 'returns an array of issues with no milestone' do get api("#{base_url}/issues", user), params: { milestone: no_milestone_title } - expect_paginated_array_response(size: 1) - expect(json_response.first['id']).to eq(confidential_issue.id) + expect_paginated_array_response(confidential_issue.id) end it 'returns an array of issues with any milestone' do get api("#{base_url}/issues", user), params: { milestone: any_milestone_title } - response_ids = json_response.map { |issue| issue['id'] } - - expect_paginated_array_response(size: 2) - expect(response_ids).to contain_exactly(closed_issue.id, issue.id) + expect_paginated_array_response([issue.id, closed_issue.id]) end it 'sorts by created_at descending by default' do get api("#{base_url}/issues", user) - response_dates = json_response.map { |issue| issue['created_at'] } - - expect_paginated_array_response(size: 3) - expect(response_dates).to eq(response_dates.sort.reverse) + expect_paginated_array_response([issue.id, confidential_issue.id, closed_issue.id]) end it 'sorts ascending when requested' do get api("#{base_url}/issues", user), params: { sort: :asc } - response_dates = json_response.map { |issue| issue['created_at'] } - - expect_paginated_array_response(size: 3) - expect(response_dates).to eq(response_dates.sort) + expect_paginated_array_response([closed_issue.id, confidential_issue.id, issue.id]) end it 'sorts by updated_at descending when requested' do get api("#{base_url}/issues", user), params: { order_by: :updated_at } - response_dates = json_response.map { |issue| issue['updated_at'] } + issue.touch(:updated_at) - expect_paginated_array_response(size: 3) - expect(response_dates).to eq(response_dates.sort.reverse) + expect_paginated_array_response([issue.id, confidential_issue.id, closed_issue.id]) end it 'sorts by updated_at ascending when requested' do get api("#{base_url}/issues", user), params: { order_by: :updated_at, sort: :asc } - response_dates = json_response.map { |issue| issue['updated_at'] } - - expect_paginated_array_response(size: 3) - expect(response_dates).to eq(response_dates.sort) + expect_paginated_array_response([closed_issue.id, confidential_issue.id, issue.id]) end end @@ -1828,21 +1740,21 @@ describe API::Issues do it 'return public project issues' do get api("/projects/#{project.id}/issues/#{issue.iid}/closed_by") - expect_paginated_array_response(size: 1) + expect_paginated_array_response(merge_request.id) end end it 'returns merge requests that will close issue on merge' do get api("/projects/#{project.id}/issues/#{issue.iid}/closed_by", user) - expect_paginated_array_response(size: 1) + expect_paginated_array_response(merge_request.id) end context 'when no merge requests will close issue' do it 'returns empty array' do get api("/projects/#{project.id}/issues/#{closed_issue.iid}/closed_by", user) - expect_paginated_array_response(size: 0) + expect_paginated_array_response([]) end end @@ -1878,7 +1790,7 @@ describe API::Issues do it 'return list of referenced merge requests from issue' do get_related_merge_requests(project.id, issue.iid) - expect_paginated_array_response(size: 1) + expect_paginated_array_response(related_mr.id) end it 'renders 404 if project is not visible' do @@ -1902,15 +1814,14 @@ describe API::Issues do get_related_merge_requests(project.id, issue.iid, user) - expect_paginated_array_response(size: 1) - expect(json_response.first['id']).to eq(related_mr.id) + expect_paginated_array_response(related_mr.id) end context 'no merge request mentioned a issue' do it 'returns empty array' do get_related_merge_requests(project.id, closed_issue.iid, user) - expect_paginated_array_response(size: 0) + expect_paginated_array_response([]) end end @@ -1948,13 +1859,6 @@ describe API::Issues do end end - def expect_paginated_array_response(size: nil) - expect(response).to have_gitlab_http_status(200) - expect(response).to include_pagination_headers - expect(json_response).to be_an Array - expect(json_response.length).to eq(size) if size - end - describe 'GET projects/:id/issues/:issue_iid/participants' do it_behaves_like 'issuable participants endpoint' do let(:entity) { issue } diff --git a/spec/requests/api/pipelines_spec.rb b/spec/requests/api/pipelines_spec.rb index eb002de62a2..52599db9a9e 100644 --- a/spec/requests/api/pipelines_spec.rb +++ b/spec/requests/api/pipelines_spec.rb @@ -456,8 +456,8 @@ describe API::Pipelines do expect(json_response['message']).to eq '404 Not found' end - it 'logs an audit event' do - expect { delete api("/projects/#{project.id}/pipelines/#{pipeline.id}", owner) }.to change { SecurityEvent.count }.by(1) + it 'does not log an audit event' do + expect { delete api("/projects/#{project.id}/pipelines/#{pipeline.id}", owner) }.not_to change { SecurityEvent.count } end context 'when the pipeline has jobs' do diff --git a/spec/requests/api/project_clusters_spec.rb b/spec/requests/api/project_clusters_spec.rb index e34164aa66a..9bab1f95150 100644 --- a/spec/requests/api/project_clusters_spec.rb +++ b/spec/requests/api/project_clusters_spec.rb @@ -266,6 +266,23 @@ describe API::ProjectClusters do end end end + + context 'when user tries to add multiple clusters' do + before do + create(:cluster, :provided_by_gcp, :project, + projects: [project]) + + post api("/projects/#{project.id}/clusters/user", current_user), params: cluster_params + end + + it 'should respond with 403' do + expect(response).to have_gitlab_http_status(403) + end + + it 'should return an appropriate message' do + expect(json_response['message']).to include('Instance does not support multiple Kubernetes clusters') + end + end end describe 'PUT /projects/:id/clusters/:cluster_id' do diff --git a/spec/requests/api/release/links_spec.rb b/spec/requests/api/release/links_spec.rb index 9d62257d470..ba948e37e2f 100644 --- a/spec/requests/api/release/links_spec.rb +++ b/spec/requests/api/release/links_spec.rb @@ -74,16 +74,6 @@ describe API::Release::Links do end end end - - context 'when feature flag is disabled' do - before do - stub_feature_flags(releases_page: false) - end - - it_behaves_like '404 response' do - let(:request) { get api("/projects/#{project.id}/releases/v0.1/assets/links", maintainer) } - end - end end describe 'GET /projects/:id/releases/:tag_name/assets/links/:link_id' do @@ -129,16 +119,6 @@ describe API::Release::Links do end end end - - context 'when feature flag is disabled' do - before do - stub_feature_flags(releases_page: false) - end - - it_behaves_like '404 response' do - let(:request) { get api("/projects/#{project.id}/releases/non_existing_tag/assets/links/#{release_link.id}", maintainer) } - end - end end describe 'POST /projects/:id/releases/:tag_name/assets/links' do @@ -231,19 +211,6 @@ describe API::Release::Links do end end end - - context 'when feature flag is disabled' do - before do - stub_feature_flags(releases_page: false) - end - - it_behaves_like '404 response' do - let(:request) do - post api("/projects/#{project.id}/releases/v0.1/assets/links", maintainer), - params: params - end - end - end end describe 'PUT /projects/:id/releases/:tag_name/assets/links/:link_id' do @@ -328,19 +295,6 @@ describe API::Release::Links do end end end - - context 'when feature flag is disabled' do - before do - stub_feature_flags(releases_page: false) - end - - it_behaves_like '404 response' do - let(:request) do - put api("/projects/#{project.id}/releases/v0.1/assets/links/#{release_link.id}", maintainer), - params: params - end - end - end end describe 'DELETE /projects/:id/releases/:tag_name/assets/links/:link_id' do @@ -401,17 +355,5 @@ describe API::Release::Links do end end end - - context 'when feature flag is disabled' do - before do - stub_feature_flags(releases_page: false) - end - - it_behaves_like '404 response' do - let(:request) do - delete api("/projects/#{project.id}/releases/v0.1/assets/links/#{release_link.id}", maintainer) - end - end - end end end diff --git a/spec/requests/api/releases_spec.rb b/spec/requests/api/releases_spec.rb index 978fa0142c2..811e23fb854 100644 --- a/spec/requests/api/releases_spec.rb +++ b/spec/requests/api/releases_spec.rb @@ -83,18 +83,6 @@ describe API::Releases do end end end - - context 'when feature flag is disabled' do - before do - stub_feature_flags(releases_page: false) - end - - it 'cannot find the API' do - get api("/projects/#{project.id}/releases", maintainer) - - expect(response).to have_gitlab_http_status(:not_found) - end - end end describe 'GET /projects/:id/releases/:tag_name' do @@ -205,18 +193,6 @@ describe API::Releases do end end end - - context 'when feature flag is disabled' do - before do - stub_feature_flags(releases_page: false) - end - - it 'cannot find the API' do - get api("/projects/#{project.id}/releases/v0.1", maintainer) - - expect(response).to have_gitlab_http_status(:not_found) - end - end end describe 'POST /projects/:id/releases' do @@ -458,18 +434,6 @@ describe API::Releases do expect(response).to have_gitlab_http_status(:conflict) end end - - context 'when feature flag is disabled' do - before do - stub_feature_flags(releases_page: false) - end - - it 'cannot find the API' do - post api("/projects/#{project.id}/releases", maintainer), params: params - - expect(response).to have_gitlab_http_status(:not_found) - end - end end describe 'PUT /projects/:id/releases/:tag_name' do @@ -565,19 +529,6 @@ describe API::Releases do end end end - - context 'when feature flag is disabled' do - before do - stub_feature_flags(releases_page: false) - end - - it 'cannot find the API' do - put api("/projects/#{project.id}/releases/v0.1", non_project_member), - params: params - - expect(response).to have_gitlab_http_status(:not_found) - end - end end describe 'DELETE /projects/:id/releases/:tag_name' do @@ -648,17 +599,5 @@ describe API::Releases do end end end - - context 'when feature flag is disabled' do - before do - stub_feature_flags(releases_page: false) - end - - it 'cannot find the API' do - delete api("/projects/#{project.id}/releases/v0.1", non_project_member) - - expect(response).to have_gitlab_http_status(:not_found) - end - end end end diff --git a/spec/requests/api/repositories_spec.rb b/spec/requests/api/repositories_spec.rb index b6b57803a6a..0adc95cfbeb 100644 --- a/spec/requests/api/repositories_spec.rb +++ b/spec/requests/api/repositories_spec.rb @@ -166,12 +166,13 @@ describe API::Repositories do get api(route, current_user) expect(response).to have_gitlab_http_status(200) + expect(headers[Gitlab::Workhorse::DETECT_HEADER]).to eq "true" end - it 'forces attachment content disposition' do + it 'sets inline content disposition by default' do get api(route, current_user) - expect(headers['Content-Disposition']).to eq 'attachment' + expect(headers['Content-Disposition']).to eq 'inline' end context 'when sha does not exist' do diff --git a/spec/services/ci/destroy_pipeline_service_spec.rb b/spec/services/ci/destroy_pipeline_service_spec.rb index 097daf67feb..d896f990470 100644 --- a/spec/services/ci/destroy_pipeline_service_spec.rb +++ b/spec/services/ci/destroy_pipeline_service_spec.rb @@ -17,8 +17,8 @@ describe ::Ci::DestroyPipelineService do expect { pipeline.reload }.to raise_error(ActiveRecord::RecordNotFound) end - it 'logs an audit event' do - expect { subject }.to change { SecurityEvent.count }.by(1) + it 'does not log an audit event' do + expect { subject }.not_to change { SecurityEvent.count } end context 'when the pipeline has jobs' do diff --git a/spec/services/ci/process_pipeline_service_spec.rb b/spec/services/ci/process_pipeline_service_spec.rb index 7ce7d2d882a..6674d89518e 100644 --- a/spec/services/ci/process_pipeline_service_spec.rb +++ b/spec/services/ci/process_pipeline_service_spec.rb @@ -762,7 +762,7 @@ describe Ci::ProcessPipelineService, '#execute' do end def manual_actions - pipeline.manual_actions(true) + pipeline.manual_actions.reload end def create_build(name, **opts) diff --git a/spec/support/helpers/api_helpers.rb b/spec/support/helpers/api_helpers.rb index a57a3b2cf34..4a9ce9beb78 100644 --- a/spec/support/helpers/api_helpers.rb +++ b/spec/support/helpers/api_helpers.rb @@ -36,4 +36,11 @@ module ApiHelpers full_path end + + def expect_paginated_array_response(items) + expect(response).to have_gitlab_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.map { |item| item['id'] }).to eq(Array(items)) + end end diff --git a/spec/support/helpers/kubernetes_helpers.rb b/spec/support/helpers/kubernetes_helpers.rb index e7d97561bfc..6930b809048 100644 --- a/spec/support/helpers/kubernetes_helpers.rb +++ b/spec/support/helpers/kubernetes_helpers.rb @@ -20,6 +20,13 @@ module KubernetesHelpers WebMock.stub_request(:get, api_url + '/apis/serving.knative.dev/v1alpha1').to_return(kube_response(kube_v1alpha1_serving_knative_discovery_body)) end + def stub_kubeclient_service_pods(response = nil) + stub_kubeclient_discover(service.api_url) + pods_url = service.api_url + "/api/v1/pods" + + WebMock.stub_request(:get, pods_url).to_return(response || kube_pods_response) + end + def stub_kubeclient_pods(response = nil) stub_kubeclient_discover(service.api_url) pods_url = service.api_url + "/api/v1/namespaces/#{service.actual_namespace}/pods" @@ -212,6 +219,13 @@ module KubernetesHelpers } end + def kube_knative_pods_body(name, namespace) + { + "kind" => "PodList", + "items" => [kube_knative_pod(name: name, namespace: namespace)] + } + end + def kube_knative_services_body(**options) { "kind" => "List", @@ -242,6 +256,28 @@ module KubernetesHelpers } end + # Similar to a kube_pod, but should contain a running service + def kube_knative_pod(name: "kube-pod", namespace: "default", status: "Running") + { + "metadata" => { + "name" => name, + "namespace" => namespace, + "generate_name" => "generated-name-with-suffix", + "creationTimestamp" => "2016-11-25T19:55:19Z", + "labels" => { + "serving.knative.dev/service" => name + } + }, + "spec" => { + "containers" => [ + { "name" => "container-0" }, + { "name" => "container-1" } + ] + }, + "status" => { "phase" => status } + } + end + def kube_deployment(name: "kube-deployment", app: "valid-deployment-label", track: nil) { "metadata" => { @@ -265,10 +301,10 @@ module KubernetesHelpers def kube_service(name: "kubetest", namespace: "default", domain: "example.com") { "metadata" => { - "creationTimestamp" => "2018-11-21T06:16:33Z", - "name" => name, - "namespace" => namespace, - "selfLink" => "/apis/serving.knative.dev/v1alpha1/namespaces/#{namespace}/services/#{name}" + "creationTimestamp" => "2018-11-21T06:16:33Z", + "name" => name, + "namespace" => namespace, + "selfLink" => "/apis/serving.knative.dev/v1alpha1/namespaces/#{namespace}/services/#{name}" }, "spec" => { "generation" => 2 diff --git a/spec/views/help/instance_configuration.html.haml_spec.rb b/spec/views/help/instance_configuration.html.haml_spec.rb index f30b5881fde..ceb7e34a540 100644 --- a/spec/views/help/instance_configuration.html.haml_spec.rb +++ b/spec/views/help/instance_configuration.html.haml_spec.rb @@ -13,9 +13,9 @@ describe 'help/instance_configuration' do it 'has links to several sections' do render - expect(rendered).to have_link(nil, '#ssh-host-keys-fingerprints') if ssh_settings.any? - expect(rendered).to have_link(nil, '#gitlab-pages') - expect(rendered).to have_link(nil, '#gitlab-ci') + expect(rendered).to have_link(nil, href: '#ssh-host-keys-fingerprints') if ssh_settings.any? + expect(rendered).to have_link(nil, href: '#gitlab-pages') + expect(rendered).to have_link(nil, href: '#gitlab-ci') end it 'has several sections' do diff --git a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb index ec20c346234..2852aa380b2 100644 --- a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb +++ b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb @@ -51,28 +51,10 @@ describe 'layouts/nav/sidebar/_project' do end describe 'releases entry' do - describe 'when releases feature flag is disabled' do - before do - stub_feature_flags(releases_page: false) - end - - it 'does not render releases link' do - render - - expect(rendered).not_to have_link('Releases', href: project_releases_path(project)) - end - end - - describe 'when releases feature flags is enabled' do - before do - stub_feature_flags(releases_page: true) - end - - it 'renders releases link' do - render + it 'renders releases link' do + render - expect(rendered).to have_link('Releases', href: project_releases_path(project)) - end + expect(rendered).to have_link('Releases', href: project_releases_path(project)) end end end diff --git a/spec/views/projects/commit/show.html.haml_spec.rb b/spec/views/projects/commit/show.html.haml_spec.rb index a9c32122600..d07099489e5 100644 --- a/spec/views/projects/commit/show.html.haml_spec.rb +++ b/spec/views/projects/commit/show.html.haml_spec.rb @@ -54,9 +54,9 @@ describe 'projects/commit/show.html.haml' do end it 'shows that it is in the context of a merge request' do - merge_request_url = diffs_project_merge_request_url(project, merge_request, commit_id: commit.id) + merge_request_url = diffs_project_merge_request_path(project, merge_request, commit_id: commit.id) expect(rendered).to have_content("This commit is part of merge request") - expect(rendered).to have_link(merge_request.to_reference, merge_request_url) + expect(rendered).to have_link(merge_request.to_reference, href: merge_request_url) end end end diff --git a/yarn.lock b/yarn.lock index 40a8594ee6c..da00f335362 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3034,10 +3034,10 @@ decamelize@^2.0.0: dependencies: xregexp "4.0.0" -deckar01-task_list@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/deckar01-task_list/-/deckar01-task_list-2.0.0.tgz#7f7a595430d21b3036ed5dfbf97d6b65de18e2c9" - integrity sha1-f3pZVDDSGzA27V37+X1rZd4Y4sk= +deckar01-task_list@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/deckar01-task_list/-/deckar01-task_list-2.0.1.tgz#fdcfb6ab5717055a82f29e863a49990a043a06a9" + integrity sha512-i5fT8QxJ9iV6dfgy5U0NHW91O5cKsvDc4u8JNMnZ6efQc356bA9vKuXO3732agSry+bO6TolzTmuqSRi4tkkeA== decode-uri-component@^0.2.0: version "0.2.0" |