Commit cd753a8c authored by GitLab Release Tools Bot's avatar GitLab Release Tools Bot

Merge branch 'master' of gitlab.com:gitlab-org/gitlab-ee

parents bf079a07 db38f9ae
......@@ -35,4 +35,10 @@ if (BABEL_ENV === 'karma' || BABEL_ENV === 'coverage') {
plugins.push('babel-plugin-rewire');
}
// Jest is running in node environment
if (BABEL_ENV === 'jest') {
plugins.push('transform-es2015-modules-commonjs');
plugins.push('dynamic-import-node');
}
module.exports = { presets, plugins };
......@@ -2,6 +2,7 @@
/config/
/builds/
/coverage/
/coverage-frontend/
/coverage-javascript/
/node_modules/
/public/
......
......@@ -78,5 +78,5 @@ eslint-report.html
/plugins/*
/.gitlab_pages_secret
package-lock.json
/junit_rspec.xml
/junit_karma.xml
/junit_*.xml
/coverage-frontend/
......@@ -877,6 +877,32 @@ karma:
reports:
junit: junit_karma.xml
jest:
<<: *dedicated-no-docs-and-no-qa-pull-cache-job
<<: *use-pg
dependencies:
- compile-assets
- setup-test-env
script:
- scripts/gitaly-test-spawn
- date
- bundle exec rake karma:fixtures
- date
- yarn jest --ci --coverage
artifacts:
name: coverage-frontend
expire_in: 31d
when: always
paths:
- coverage-frontend/
- junit_jest.xml
reports:
junit: junit_jest.xml
cache:
key: jest
paths:
- tmp/jest/jest/
code_quality:
<<: *dedicated-no-docs-no-db-pull-cache-job
image: docker:stable
......
......@@ -14,6 +14,7 @@ const Api = {
projectMergeRequestPath: '/api/:version/projects/:id/merge_requests/:mrid',
projectMergeRequestChangesPath: '/api/:version/projects/:id/merge_requests/:mrid/changes',
projectMergeRequestVersionsPath: '/api/:version/projects/:id/merge_requests/:mrid/versions',
projectRunnersPath: '/api/:version/projects/:id/runners',
mergeRequestsPath: '/api/:version/merge_requests',
groupLabelsPath: '/groups/:namespace_path/-/labels',
ldapGroupsPath: '/api/:version/ldap/:provider/groups.json',
......@@ -126,6 +127,15 @@ const Api = {
return axios.get(url);
},
projectRunners(projectPath, config = {}) {
const url = Api.buildUrl(Api.projectRunnersPath).replace(
':id',
encodeURIComponent(projectPath),
);
return axios.get(url, config);
},
mergeRequests(params = {}) {
const url = Api.buildUrl(Api.mergeRequestsPath);
......
......@@ -147,8 +147,14 @@ export const scrollToLineIfNeededParallel = (_, line) => {
}
};
export const loadCollapsedDiff = ({ commit }, file) =>
axios.get(file.load_collapsed_diff_url).then(res => {
export const loadCollapsedDiff = ({ commit, getters }, file) =>
axios
.get(file.load_collapsed_diff_url, {
params: {
commit_id: getters.commitId,
},
})
.then(res => {
commit(types.ADD_COLLAPSED_DIFFS, {
file,
data: res.data,
......
......@@ -130,7 +130,7 @@ export default {
if (file.highlighted_diff_lines) {
file.highlighted_diff_lines = file.highlighted_diff_lines.map(line => {
if (lineCheck(line)) {
if (!line.discussions.some(({ id }) => discussion.id === id) && lineCheck(line)) {
return {
...line,
discussions: line.discussions.concat(discussion),
......@@ -150,11 +150,17 @@ export default {
return {
left: {
...line.left,
discussions: left ? line.left.discussions.concat(discussion) : [],
discussions:
left && !line.left.discussions.some(({ id }) => id === discussion.id)
? line.left.discussions.concat(discussion)
: (line.left && line.left.discussions) || [],
},
right: {
...line.right,
discussions: right && !left ? line.right.discussions.concat(discussion) : [],
discussions:
right && !left && !line.right.discussions.some(({ id }) => id === discussion.id)
? line.right.discussions.concat(discussion)
: (line.right && line.right.discussions) || [],
},
};
}
......
import Vue from 'vue';
import { mapActions } from 'vuex';
import _ from 'underscore';
import Translate from '~/vue_shared/translate';
import ide from './components/ide.vue';
import store from './stores';
......@@ -13,19 +14,19 @@ Vue.use(Translate);
*
* @param {Element} el - The element that will contain the IDE.
* @param {Object} options - Extra options for the IDE (Used by EE).
* @param {(e:Element) => Object} options.extraInitialData -
* Function that returns extra properties to seed initial data.
* @param {Component} options.rootComponent -
* Component that overrides the root component.
* @param {(store:Vuex.Store, el:Element) => Vuex.Store} options.extendStore -
* Function that receives the default store and returns an extended one.
*/
export function initIde(el, options = {}) {
if (!el) return null;
const { extraInitialData = () => ({}), rootComponent = ide } = options;
const { rootComponent = ide, extendStore = _.identity } = options;
return new Vue({
el,
store,
store: extendStore(store, el),
router,
created() {
this.setEmptyStateSvgs({
......@@ -41,7 +42,6 @@ export function initIde(el, options = {}) {
});
this.setInitialData({
clientsidePreviewEnabled: parseBoolean(el.dataset.clientsidePreviewEnabled),
...extraInitialData(el),
});
},
methods: {
......
......@@ -16,7 +16,9 @@ const httpStatusCodes = {
IM_USED: 226,
MULTIPLE_CHOICES: 300,
BAD_REQUEST: 400,
FORBIDDEN: 403,
NOT_FOUND: 404,
UNPROCESSABLE_ENTITY: 422,
};
export const successCodes = [
......
......@@ -19,3 +19,4 @@ $info: $blue-500;
$warning: $orange-500;
$danger: $red-500;
$zindex-modal-backdrop: 1040;
$nav-divider-margin-y: ($grid-size / 2);
......@@ -9,7 +9,7 @@ class Projects::JobsController < Projects::ApplicationController
before_action :authorize_update_build!,
except: [:index, :show, :status, :raw, :trace, :cancel_all, :erase]
before_action :authorize_erase_build!, only: [:erase]
before_action :authorize_use_build_terminal!, only: [:terminal, :terminal_workhorse_authorize]
before_action :authorize_use_build_terminal!, only: [:terminal, :terminal_websocket_authorize]
before_action :verify_api_request!, only: :terminal_websocket_authorize
layout 'project'
......
......@@ -24,12 +24,9 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
def render_diffs
@environment = @merge_request.environments_for(current_user).last
notes_grouped_by_path = renderable_notes.group_by { |note| note.position.file_path }
@diffs.diff_files.each do |diff_file|
notes = notes_grouped_by_path.fetch(diff_file.file_path, [])
notes.each { |note| diff_file.unfold_diff_lines(note.position) }
end
note_positions = renderable_notes.map(&:position).compact
@diffs.unfold_diff_files(note_positions)
@diffs.write_cache
......
# frozen_string_literal: true
module IdeHelper
def ide_data
{
"empty-state-svg-path" => image_path('illustrations/multi_file_editor_empty.svg'),
"no-changes-state-svg-path" => image_path('illustrations/multi-editor_no_changes_empty.svg'),
"committed-state-svg-path" => image_path('illustrations/multi-editor_all_changes_committed_empty.svg'),
"pipelines-empty-state-svg-path": image_path('illustrations/pipelines_empty.svg'),
"promotion-svg-path": image_path('illustrations/web-ide_promotion.svg'),
"ci-help-page-path" => help_page_path('ci/quick_start/README'),
"web-ide-help-page-path" => help_page_path('user/project/web_ide/index.html'),
"clientside-preview-enabled": Gitlab::CurrentSettings.current_application_settings.web_ide_clientside_preview_enabled.to_s
}
end
end
......@@ -114,7 +114,8 @@ module Ci
cached_attr_reader :version, :revision, :platform, :architecture, :ip_address, :contacted_at
chronic_duration_attr :maximum_timeout_human_readable, :maximum_timeout
chronic_duration_attr :maximum_timeout_human_readable, :maximum_timeout,
error_message: 'Maximum job timeout has a value which could not be accepted'
validates :maximum_timeout, allow_nil: true,
numericality: { greater_than_or_equal_to: 600,
......
......@@ -24,7 +24,7 @@ module ChronicDurationAttribute
end
end
validates virtual_attribute, allow_nil: true, duration: true
validates virtual_attribute, allow_nil: true, duration: { message: parameters[:error_message] }
end
alias_method :chronic_duration_attr, :chronic_duration_attr_writer
......
......@@ -404,7 +404,8 @@ class Project < ActiveRecord::Base
enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 }
chronic_duration_attr :build_timeout_human_readable, :build_timeout, default: 3600
chronic_duration_attr :build_timeout_human_readable, :build_timeout,
default: 3600, error_message: 'Maximum job timeout has a value which could not be accepted'
validates :build_timeout, allow_nil: true,
numericality: { greater_than_or_equal_to: 10.minutes,
......
......@@ -14,6 +14,10 @@ class DurationValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
ChronicDuration.parse(value)
rescue ChronicDuration::DurationParseError
if options[:message]
record.errors.add(:base, options[:message])
else
record.errors.add(attribute, "is not a correct duration")
end
end
end
- @body_class = 'ide-layout'
- page_title 'IDE'
- content_for :page_specific_javascripts do
= stylesheet_link_tag 'page_bundles/ide'
#ide.ide-loading{ data: ide_data() }
.text-center
= icon('spinner spin 2x')
%h2.clgray= _('Loading the GitLab IDE...')
- @body_class = 'ide-layout'
- page_title 'IDE'
- content_for :page_specific_javascripts do
= stylesheet_link_tag 'page_bundles/ide'
#ide.ide-loading{ data: {"empty-state-svg-path" => image_path('illustrations/multi_file_editor_empty.svg'),
"no-changes-state-svg-path" => image_path('illustrations/multi-editor_no_changes_empty.svg'),
"committed-state-svg-path" => image_path('illustrations/multi-editor_all_changes_committed_empty.svg'),
"pipelines-empty-state-svg-path": image_path('illustrations/pipelines_empty.svg'),
"promotion-svg-path": image_path('illustrations/web-ide_promotion.svg'),
"ci-help-page-path" => help_page_path('ci/quick_start/README'),
"web-ide-help-page-path" => help_page_path('user/project/web_ide/index.html'),
"clientside-preview-enabled": Gitlab::CurrentSettings.current_application_settings.web_ide_clientside_preview_enabled.to_s } }
.text-center
= icon('spinner spin 2x')
%h2.clgray= _('Loading the GitLab IDE...')
= render 'ide/show'
- page_title import_in_progress_title
- @no_container = true
- @content_class = "limit-container-width" unless fluid_layout
.save-project-loader
.center
......
......@@ -29,7 +29,7 @@
= f.label :build_timeout_human_readable, _('Timeout'), class: 'label-bold'
= f.text_field :build_timeout_human_readable, class: 'form-control'
%p.form-text.text-muted
= _("Per job. If a job passes this threshold, it will be marked as failed")
= _('If any job surpasses this timeout threshold, it will be marked as failed. Human readable time input language is accepted like "1 hour". Values without specification represent seconds.')
= link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'timeout'), target: '_blank'
%hr
......
......@@ -45,7 +45,7 @@
= _('Maximum job timeout')
.col-sm-10
= f.text_field :maximum_timeout_human_readable, class: 'form-control'
.form-text.text-muted= _('This timeout will take precedence when lower than Project-defined timeout')
.form-text.text-muted= _('This timeout will take precedence when lower than project-defined timeout and accepts a human readable time input language like "1 hour". Values without specification represent seconds.')
.form-group.row
= label_tag :tag_list, class: 'col-form-label col-sm-2' do
= _('Tags')
......
---
title: Use read_repository scope on read-only files API
merge_request: 23534
author:
type: fixed
---
title: Fixed diff files expanding not loading commit content
merge_request:
author:
type: fixed
---
title: Improve help and validation sections of maximum build timeout inputs
merge_request: 23586
author:
type: fixed
---
title: Change container width for project import
merge_request: 23318
author: George Tsiolis
type: fixed
---
title: Fixed duplicate discussions getting added to diff lines
merge_request:
author:
type: fixed
---
title: Avoid 500's when serializing legacy diff notes
merge_request: 23544
author:
type: fixed
---
title: Adjust divider margin to comply with design specs
merge_request: 23548
author:
type: changed
/* eslint-disable filenames/match-regex */
const reporters = ['default'];
if (process.env.CI) {
reporters.push([
'jest-junit',
{
output: './junit_jest.xml',
},
]);
}
// eslint-disable-next-line import/no-commonjs
module.exports = {
testMatch: ['<rootDir>/spec/frontend/**/*_spec.js'],
moduleNameMapper: {
'^~(.*)$': '<rootDir>/app/assets/javascripts$1',
},
collectCoverageFrom: ['<rootDir>/app/assets/javascripts/**/*.{js,vue}'],
coverageDirectory: '<rootDir>/coverage-frontend/',
coverageReporters: ['json', 'lcov', 'text-summary', 'clover'],
cacheDirectory: '<rootDir>/tmp/cache/jest',
modulePathIgnorePatterns: ['<rootDir>/.yarn-cache/'],
reporters,
rootDir: '..', // necessary because this file is in the config/ subdirectory
};
......@@ -2,7 +2,7 @@
require 'yaml'
NO_CHANGELOG_LABELS = %w[backstage Documentation QA test].freeze
NO_CHANGELOG_LABELS = %w[backstage ci-build Documentation meta QA test].freeze
SEE_DOC = "See [the documentation](https://docs.gitlab.com/ce/development/changelog.html).".freeze
CREATE_CHANGELOG_MESSAGE = <<~MSG.freeze
You can create one with:
......
......@@ -2834,6 +2834,7 @@ ActiveRecord::Schema.define(version: 20181204135932) do
t.integer "merge_request_notes_filter", limit: 2, default: 0, null: false
t.datetime_with_timezone "created_at", null: false
t.datetime_with_timezone "updated_at", null: false
t.string "epics_sort"
t.index ["user_id"], name: "index_user_preferences_on_user_id", unique: true, using: :btree
end
......
......@@ -4,6 +4,16 @@
**Create, read, update and delete repository files using this API**
The different scopes available using [personal access tokens](../user/profile/personal_access_tokens.md) are depicted
in the following table.
| Scope | Description |
| ----- | ----------- |
| `read_repository` | Allows read-access to the repository files. |
| `api` | Allows read-write access to the repository files. |
> `read_repository` scope was [introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/23534) in GitLab 11.6.
## Get file from repository
Allows you to receive information about file in repository like name, size,
......
......@@ -57,12 +57,14 @@ are very appreciative of the work done by translators and proofreaders!
- Chang-Ho Cha - [GitLab](https://gitlab.com/changho-cha), [Crowdin](https://crowdin.com/profile/zzazang)
- Huang Tao - [GitLab](https://gitlab.com/htve), [Crowdin](https://crowdin.com/profile/htve)
- Ji Hun Oh - [GitLab](https://gitlab.com/Baw-Appie), [Crowdin](https://crowdin.com/profile/BawAppie)
- Jeongwhan Choi - [GitLab](https://gitlab.com/jeongwhanchoi), [Crowdin](https://crowdin.com/profile/jeongwhanchoi)
- Mongolian
- Proofreaders needed.
- Norwegian Bokmal
- Proofreaders needed.
- Polish
- Filip Mech - [GitLab](https://gitlab.com/mehenz), [Crowdin](https://crowdin.com/profile/mehenz)
- Maksymilian Roman - [GitLab](https://gitlab.com/villaincandle), [Crowdin](https://crowdin.com/profile/villaincandle)
- Portuguese
- Proofreaders needed.
- Portuguese, Brazilian
......
......@@ -6,9 +6,15 @@ Tests relevant for frontend development can be found at two places:
- [frontend unit tests](#frontend-unit-tests)
- [frontend component tests](#frontend-component-tests)
- [frontend integration tests](#frontend-integration-tests)
- `spec/frontend/` which are run by Jest and contain
- [frontend unit tests](#frontend-unit-tests)
- [frontend component tests](#frontend-component-tests)
- [frontend integration tests](#frontend-integration-tests)
- `spec/features/` which are run by RSpec and contain
- [feature tests](#feature-tests)
All tests in `spec/javascripts/` will eventually be migrated to `spec/frontend/` (see also [#53757]).
In addition there were feature tests in `features/` run by Spinach in the past.
These have been removed from our codebase in May 2018 ([#23036](https://gitlab.com/gitlab-org/gitlab-ce/issues/23036)).
......@@ -17,6 +23,8 @@ See also:
- [old testing guide](../../testing_guide/frontend_testing.html)
- [notes on testing Vue components](../../fe_guide/vue.html#testing-vue-components)
[#53757]: https://gitlab.com/gitlab-org/gitlab-ce/issues/53757
## Frontend unit tests
Unit tests are on the lowest abstraction level and typically test functionality that is not directly perceivable by a user.
......
......@@ -7,9 +7,10 @@ GitLab provides official Docker images to allowing you to easily take advantage
## Omnibus GitLab based images
GitLab maintains a set of [official Docker images](https://hub.docker.com/r/gitlab) based on our [Omnibus GitLab package](https://docs.gitlab.com/omnibus/README.html). These images include:
* [GitLab Community Edition](https://hub.docker.com/r/gitlab/gitlab-ce/)
* [GitLab Enterprise Edition](https://hub.docker.com/r/gitlab/gitlab-ee/)
* [GitLab Runner](https://hub.docker.com/r/gitlab/gitlab-runner/)
- [GitLab Community Edition](https://hub.docker.com/r/gitlab/gitlab-ce/).
- [GitLab Enterprise Edition](https://hub.docker.com/r/gitlab/gitlab-ee/).
- [GitLab Runner](https://hub.docker.com/r/gitlab/gitlab-runner/).
A [complete usage guide](https://docs.gitlab.com/omnibus/docker/) to these images is available, as well as the [Dockerfile used for building the images](https://gitlab.com/gitlab-org/omnibus-gitlab/tree/master/docker).
......
......@@ -6,7 +6,7 @@ password to login, they'll be prompted for a code generated by an application on
their phone.
You can read more about it here:
[Two-factor Authentication (2FA)](../profile/two_factor_authentication.md)
[Two-factor Authentication (2FA)](../user/profile/account/two_factor_authentication.md)
## Enforcing 2FA for all users
......
......@@ -123,6 +123,8 @@ You can also sort epics list by:
- **Start date**
- **Due date**
Each option contains a button that can toggle the order between **ascending** and **descending**. The sort option and order will be persisted to be used wherever epics are browsed including the [roadmap](../roadmap/index.md).
![epics sort](img/epics_sort.png)
## Permissions
......
......@@ -13,6 +13,8 @@ Epics in the view can be sorted by:
- **Start date**
- **Due date**
Each option contains a button that can toggle the order between **ascending** and **descending**. The sort option and order will be persisted to be used wherever epics are browsed including the [epics list view](../epics/index.md).
![roadmap view](img/roadmap_view.png)
## Timeline duration
......
<script>
import { mapState } from 'vuex';
import { __ } from '~/locale';
import RightPane from '~/ide/components/panes/right.vue';
import TerminalView from '../terminal/view.vue';
......@@ -8,17 +9,14 @@ export default {
components: {
RightPane,
},
data() {
return {
// this will come from Vuex store in https://gitlab.com/gitlab-org/gitlab-ee/issues/5426
isTerminalEnabled: false,
};
},
computed: {
...mapState('terminal', {
isTerminalVisible: 'isVisible',
}),
extensionTabs() {
return [
{
show: this.isTerminalEnabled,
show: this.isTerminalVisible,
title: __('Terminal'),
views: [{ name: 'terminal', keepAlive: true, component: TerminalView }],
icon: 'terminal',
......
<script>
import { GlLoadingIcon } from '@gitlab/ui';
export default {
components: {
GlLoadingIcon,
},
props: {
isLoading: {
type: Boolean,
required: false,
default: true,
},
isValid: {
type: Boolean,
required: false,
default: false,
},
message: {
type: String,
required: false,
default: '',
},
helpPath: {
type: String,
required: false,
default: '',
},
illustrationPath: {
type: String,
required: false,
default: '',
},
},
methods: {
onStart() {
this.$emit('start');
},
},
};
</script>
<template>
<div class="text-center">
<div v-if="illustrationPath" class="svg-content svg-130"><img :src="illustrationPath" /></div>
<h4>{{ __('Web Terminal') }}</h4>
<gl-loading-icon v-if="isLoading" :size="2" class="prepend-top-default" />
<template v-else>
<p>{{ __('Run tests against your code live using the Web Terminal') }}</p>
<div class="bs-callout text-left">The Web Terminal is coming soon. Stay tuned!</div>
<p>
<button :disabled="!isValid" class="btn btn-info" type="button" @click="onStart">
{{ __('Start Web Terminal') }}
</button>
</p>
<div v-if="!isValid && message" class="bs-callout text-left" v-html="message"></div>
<p v-else>
<a
v-if="helpPath"
:href="helpPath"
target="_blank"
v-text="__('Learn more about Web Terminal')"
></a>
</p>
</template>
</div>
</template>
<script>
import { mapState } from 'vuex';
import { mapActions, mapGetters, mapState } from 'vuex';
import EmptyState from './empty_state.vue';
export default {
......@@ -7,15 +7,32 @@ export default {
EmptyState,
},
computed: {
...mapState(['emptyStateSvgPath']),
...mapState('terminal', ['isShowSplash', 'paths']),
...mapGetters('terminal', ['allCheck']),
},
methods: {
...mapActions('terminal', ['hideSplash']),
start() {
this.hideSplash();
},
},
};
</script>
<template>
<div class="h-100">
<div class="h-100 d-flex flex-column justify-content-center">
<empty-state :illustration-path="emptyStateSvgPath" />
<div v-if="isShowSplash" class="h-100 d-flex flex-column justify-content-center">
<empty-state
:is-loading="allCheck.isLoading"
:is-valid="allCheck.isValid"
:message="allCheck.message"
:help-path="paths.webTerminalHelpPath"
:illustration-path="paths.webTerminalSvgPath"
@start="start();"
/>
</div>
<template v-else>
<h5>{{ __('Web Terminal') }}</h5>
</template>
</div>
</template>
export const CHECK_CONFIG = 'config';
export const CHECK_RUNNERS = 'runners';
export const RETRY_RUNNERS_INTERVAL = 10000;
import * as mutationTypes from '~/ide/stores/mutation_types';
import terminalModule from './modules/terminal';
function getPathsFromData(el) {
return {
ciYamlHelpPath: el.dataset.eeCiYamlHelpPath,
ciRunnersHelpPath: el.dataset.eeCiRunnersHelpPath,
webTerminalHelpPath: el.dataset.eeWebTerminalHelpPath,
webTerminalSvgPath: el.dataset.eeWebTerminalSvgPath,
};
}
export default (store, el) => {
store.registerModule('terminal', terminalModule());
store.dispatch('terminal/setPaths', getPathsFromData(el));
store.subscribe(({ type }) => {
if (type === mutationTypes.SET_BRANCH_WORKING_REFERENCE) {
store.dispatch('terminal/init');
}
});
return store;
};
import Api from '~/api';
import httpStatus from '~/lib/utils/http_status';
import * as types from '../mutation_types';
import * as messages from '../messages';
import { CHECK_CONFIG, CHECK_RUNNERS, RETRY_RUNNERS_INTERVAL } from '../../../../constants';
export const requestConfigCheck = ({ commit }) => {
commit(types.REQUEST_CHECK, CHECK_CONFIG);
};
export const receiveConfigCheckSuccess = ({ commit }) => {
commit(types.SET_VISIBLE, true);
commit(types.RECEIVE_CHECK_SUCCESS, CHECK_CONFIG);
};
export const receiveConfigCheckError = ({ commit, state }, e) => {
const { status } = e.response;
const { paths } = state;
const isVisible = status !== httpStatus.FORBIDDEN && status !== httpStatus.NOT_FOUND;
commit(types.SET_VISIBLE, isVisible);
const message = messages.configCheckError(status, paths.ciYamlHelpPath);
commit(types.RECEIVE_CHECK_ERROR, { type: CHECK_CONFIG, message });
};
export const fetchConfigCheck = ({ dispatch }) => {
dispatch('requestConfigCheck');
// This will use a real endpoint in https://gitlab.com/gitlab-org/gitlab-ee/issues/5426
Promise.resolve({})
.then(() => {
dispatch('receiveConfigCheckSuccess');
})
.catch(e => {
dispatch('receiveConfigCheckError', e);
});
};
export const requestRunnersCheck = ({ commit }) => {
commit(types.REQUEST_CHECK, CHECK_RUNNERS);
};
export const receiveRunnersCheckSuccess = ({ commit, dispatch, state }, data) => {
if (data.length) {
commit(types.RECEIVE_CHECK_SUCCESS, CHECK_RUNNERS);
} else {
const { paths } = state;
commit(types.RECEIVE_CHECK_ERROR, {
type: CHECK_RUNNERS,
message: messages.runnersCheckEmpty(paths.ciRunnersHelpPath),
});
dispatch('retryRunnersCheck');
}
};
export const receiveRunnersCheckError = ({ commit }) => {
commit(types.RECEIVE_CHECK_ERROR, {
type: CHECK_RUNNERS,
message: messages.UNEXPECTED_ERROR_RUNNERS,
});
};
export const retryRunnersCheck = ({ dispatch, state }) => {
// if the overall check has failed, don't worry about retrying
const check = state.checks[CHECK_CONFIG];
if (!check.isLoading && !check.isValid) {
return;
}
setTimeout(() => {
dispatch('fetchRunnersCheck', { background: true });
}, RETRY_RUNNERS_INTERVAL);
};
export const fetchRunnersCheck = ({ dispatch, rootGetters }, options = {}) => {
const { background = false } = options;
if (!background) {
dispatch('requestRunnersCheck');
}
const { currentProject } = rootGetters;
Api.projectRunners(currentProject.id, { params: { scope: 'active' } })
.then(({ data }) => {
dispatch('receiveRunnersCheckSuccess', data);
})
.catch(e => {
dispatch('receiveRunnersCheckError', e);
});
};
export * from './setup';
export * from './checks';
export default () => {};
import * as types from '../mutation_types';
// This will be used in https://gitlab.com/gitlab-org/gitlab-ee/issues/5426
// export const init = ({ dispatch }) => {
// dispatch('fetchConfigCheck');
// dispatch('fetchRunnersCheck');
// };
export const init = () => {};
export const hideSplash = ({ commit }) => {
commit(types.HIDE_SPLASH);
};
export const setPaths = ({ commit }, paths) => {
commit(types.SET_PATHS, paths);
};
export const allCheck = state => {
const checks = Object.values(state.checks);
if (checks.some(check => check.isLoading)) {
return { isLoading: true };
}
const invalidCheck = checks.find(check => !check.isValid);
const isValid = !invalidCheck;
const message = !invalidCheck ? '' : invalidCheck.message;
return {
isLoading: false,
isValid,
message,
};
};
export default () => {};
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
export default () => ({
namespaced: true,
actions,
getters,
mutations,
state: state(),
});
import _ from 'underscore';
import { __, sprintf } from '~/locale';
import httpStatus from '~/lib/utils/http_status';
export const UNEXPECTED_ERROR_CONFIG = __(
'An unexpected error occurred while checking the project environment.',
);
export const UNEXPECTED_ERROR_RUNNERS = __(
'An unexpected error occurred while checking the project runners.',
);
export const EMPTY_RUNNERS = __(
'Configure GitLab runners to start using Web Terminal. %{helpStart}Learn more.%{helpEnd}',
);
export const ERROR_CONFIG = __(
'Configure a <code>.gitlab-webide.yml</code> file in the <code>.gitlab</code> directory to start using the Web Terminal. %{helpStart}Learn more.%{helpEnd}',
);
export const ERROR_PERMISSION = __(
'You do not have permission to run the Web Terminal. Please contact a project administrator.',
);
export const configCheckError = (status, helpUrl) => {
if (status === httpStatus.UNPROCESSABLE_ENTITY) {
return sprintf(
ERROR_CONFIG,
{
helpStart: `<a href="${_.escape(helpUrl)}" target="_blank">`,
helpEnd: '</a>',
},
false,
);
} else if (status === httpStatus.FORBIDDEN) {
return ERROR_PERMISSION;
}
return UNEXPECTED_ERROR_CONFIG;
};
export const runnersCheckEmpty = helpUrl =>
sprintf(
EMPTY_RUNNERS,
{
helpStart: `<a href="${_.escape(helpUrl)}" target="_blank">`,
helpEnd: '</a>',
},
false,
);
export const SET_VISIBLE = 'SET_VISIBLE';
export const HIDE_SPLASH = 'HIDE_SPLASH';
export const SET_PATHS = 'SET_PATHS';
export const REQUEST_CHECK = 'REQUEST_CHECK';
export const RECEIVE_CHECK_SUCCESS = 'RECEIVE_CHECK_SUCCESS';
export const RECEIVE_CHECK_ERROR = 'RECEIVE_CHECK_ERROR';
import * as types from './mutation_types';
export default {
[types.SET_VISIBLE](state, isVisible) {
Object.assign(state, {
isVisible,
});
},
[types.HIDE_SPLASH](state) {
Object.assign(state, {
isShowSplash: false,
});
},
[types.SET_PATHS](state, paths) {
Object.assign(state, {
paths,
});
},
[types.REQUEST_CHECK](state, type) {
Object.assign(state.checks, {
[type]: {
isLoading: true,
},
});
},
[types.RECEIVE_CHECK_ERROR](state, { type, message }) {
Object.assign(state.checks, {
[type]: {
isLoading: false,
isValid: false,
message,
},
});
},
[types.RECEIVE_CHECK_SUCCESS](state, type) {
Object.assign(state.checks, {
[type]: {
isLoading: false,
isValid: true,
message: null,
},
});
},
};
import { CHECK_CONFIG, CHECK_RUNNERS } from '../../../constants';
export default () => ({
checks: {
[CHECK_CONFIG]: { isLoading: true },
[CHECK_RUNNERS]: { isLoading: true },
},
isVisible: false,
isShowSplash: true,
paths: {},
});
import { startIde } from '~/ide/index';
import EEIde from 'ee/ide/components/ide.vue';
function extraInitialData() {
// This is empty now, but it will be used in: https://gitlab.com/gitlab-org/gitlab-ee/issues/5426
return {};
}
import extendStore from 'ee/ide/stores/extend';
startIde({
extraInitialData,
extendStore,
rootComponent: EEIde,
});
......@@ -114,7 +114,7 @@ export default {
RelatedIssuesService.remove(issueToRemove.relation_path)
.then(res => res.json())
.then(data => {
this.store.setRelatedIssues(data.issues);
this.store.setRelatedIssues(data.issuables);
})
.catch(res => {
if (res && res.status !== 404) {
......@@ -142,7 +142,7 @@ export default {
.then(data => {
// We could potentially lose some pending issues in the interim here
this.store.setPendingReferences([]);
this.store.setRelatedIssues(data.issues);
this.store.setRelatedIssues(data.issuables);
this.isSubmitting = false;
// Close the form on submission
......
......@@ -16,7 +16,7 @@ class RelatedIssuesService {
return this.relatedIssuesResource.save(
{},
{
issue_references: newIssueReferences,
issuable_references: newIssueReferences,
},
);
}
......
......@@ -4,9 +4,21 @@ $details-cell-width: 320px;
$border-style: 1px solid $border-gray-normal;
$roadmap-gradient-dark-gray: rgba(0, 0, 0, 0.15);
$roadmap-gradient-gray: rgba(255, 255, 255, 0.001);
$scroll-top-gradient: linear-gradient(to bottom, $roadmap-gradient-dark-gray 0%, $roadmap-gradient-gray 100%);
$scroll-bottom-gradient: linear-gradient(to bottom, $roadmap-gradient-gray 0%, $roadmap-gradient-dark-gray 100%);
$column-right-gradient: linear-gradient(to right, $roadmap-gradient-dark-gray 0%, $roadmap-gradient-gray 100%);
$scroll-top-gradient: linear-gradient(
to bottom,
$roadmap-gradient-dark-gray 0%,
$roadmap-gradient-gray 100%
);
$scroll-bottom-gradient: linear-gradient(
to bottom,
$roadmap-gradient-gray 0%,
$roadmap-gradient-dark-gray 100%
);
$column-right-gradient: linear-gradient(
to right,
$roadmap-gradient-dark-gray 0%,
$roadmap-gradient-gray 100%
);
@mixin roadmap-scroll-mixin {
height: $grid-size;
......@@ -14,6 +26,39 @@ $column-right-gradient: linear-gradient(to right, $roadmap-gradient-dark-gray 0%
pointer-events: none;
}
.epics-details-filters {
.btn-group {
.dropdown-toggle {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.btn-sort-direction {
border-left: 0;
&:hover {
border-color: $gray-darkest;
}
}
@include media-breakpoint-down(xs) {
display: flex;
.dropdown-menu-sort {
// This is a hack to fix dropdown alignment in small screens
// where Bootstrap applies inline `transform: translate3d(...)`
// and since our dropdown button has sort direction button
// present, alignment needs to compensate for that space
// without which it appears shifted towards left.
//
// One more approach is to override `transform` using `!important`
// but that too involves using magic number
margin-left: 27px;
}
}
}
}
.epics-roadmap-filters {
.epics-details-filters {
.btn-roadmap-preset {
......@@ -52,7 +97,7 @@ $column-right-gradient: linear-gradient(to right, $roadmap-gradient-dark-gray 0%
.roadmap-timeline-section .timeline-header-blank::after,
.epics-list-section .epic-details-cell::after {
content: '';
content: "";
position: absolute;
top: 0;
right: -$grid-size;
......@@ -136,7 +181,7 @@ $column-right-gradient: linear-gradient(to right, $roadmap-gradient-dark-gray 0%
}
.today-bar::before {
content: '';
content: "";
position: absolute;
top: -2px;
left: -3px;
......@@ -150,7 +195,7 @@ $column-right-gradient: linear-gradient(to right, $roadmap-gradient-dark-gray 0%
&.scroll-top-shadow .timeline-header-blank::before {
@include roadmap-scroll-mixin;
content: '';
content: "";
position: absolute;
left: 0;
bottom: -$grid-size;
......@@ -256,7 +301,7 @@ $column-right-gradient: linear-gradient(to right, $roadmap-gradient-dark-gray 0%
&.start-date-outside::before,
&.end-date-outside::after {
content: '';
content: "";
position: absolute;
top: 0;
height: 100%;
......@@ -269,11 +314,21 @@ $column-right-gradient: linear-gradient(to right, $roadmap-gradient-dark-gray 0%
}
&.start-date-undefined {
background: linear-gradient(to right, $roadmap-gradient-gray 0%, $blue-200 50%, $blue-500 100%);
background: linear-gradient(
to right,
$roadmap-gradient-gray 0%,
$blue-200 50%,
$blue-500 100%
);
}
&.end-date-undefined {
background: linear-gradient(to right, $blue-500 0%, $blue-200 50%, $roadmap-gradient-gray 100%);
background: linear-gradient(
to right,
$blue-500 0%,
$blue-200 50%,
$roadmap-gradient-gray 100%
);
}
&.start-date-outside {
......
# frozen_string_literal: true
module EpicRelations
extend ActiveSupport::Concern
include IssuableLinks
included do
skip_before_action :authorize_destroy_issuable!
skip_before_action :authorize_create_epic!
skip_before_action :authorize_update_issuable!
before_action :authorize_admin_epic!, only: [:create, :destroy, :update]
end
def authorize_admin_epic!
render_403 unless can?(current_user, :admin_epic, epic)
end
end
......@@ -11,11 +11,13 @@ module EpicsActions
end
def default_sort_order
sort_value_end_date
sort_value_recently_created
end
def update_cookie_value(value)
case value
when 'created_asc' then sort_value_oldest_created
when 'created_desc' then sort_value_recently_created
when 'start_date_asc' then sort_value_start_date
when 'end_date_asc' then sort_value_end_date
else
......
......@@ -2,25 +2,33 @@
module IssuableLinks
def index
render json: issues
render json: issuables
end
def create
result = create_service.execute
render json: { message: result[:message], issues: issues }, status: result[:http_status]
render json: { message: result[:message], issuables: issuables }, status: result[:http_status]
end
def destroy
result = destroy_service.execute
render json: { issues: issues }, status: result[:http_status]
render json: { issuables: issuables }, status: result[:http_status]
end
private
def issuables
list_service.execute
end
def list_service
raise NotImplementedError
end
def create_params
params.slice(:issue_references)
params.slice(:issuable_references)
end
def create_service
......
# frozen_string_literal: true
class Groups::EpicIssuesController < Groups::EpicsController
include IssuableLinks
include EpicRelations
skip_before_action :authorize_destroy_issuable!
skip_before_action :authorize_create_epic!
skip_before_action :authorize_update_issuable!
before_action :authorize_admin_epic!, only: [:create, :destroy, :update]
before_action :authorize_issue_link_association!, only: [:destroy, :update]
def update
......@@ -26,12 +21,8 @@ class Groups::EpicIssuesController < Groups::EpicsController
EpicIssues::DestroyService.new(link, current_user)
end
def issues
EpicIssues::ListService.new(epic, current_user).execute
end
def authorize_admin_epic!
render_403 unless can?(current_user, :admin_epic, epic)
def list_service
EpicIssues::ListService.new(epic, current_user)
end
def authorize_issue_link_association!
......
......@@ -95,6 +95,10 @@ class Groups::EpicsController < Groups::ApplicationController
EpicsFinder
end
def issuable_sorting_field
:epics_sort
end
def preload_for_collection
@preload_for_collection ||= [:group, :author]
end
......
......@@ -9,14 +9,20 @@ module Groups
before_action :group
before_action :persist_roadmap_layout, only: [:show]
def show
# show roadmap for a group
@sort = set_sort_order_from_cookie || default_sort_order
def show
# Used to persist the order and show the correct sorting dropdown on UI.
@sort = set_sort_order
@epics_count = EpicsFinder.new(current_user, group_id: @group.id).execute.count
end
private
def issuable_sorting_field
:epics_sort
end
def persist_roadmap_layout
return unless current_user
......
......@@ -9,10 +9,6 @@ module Projects
private
def issues
IssueLinks::ListService.new(issue, current_user).execute
end
def authorize_admin_issue_link!
render_403 unless can?(current_user, :admin_issue_link, @project)
end
......@@ -29,6 +25,10 @@ module Projects
end
# rubocop: enable CodeReuse/ActiveRecord
def list_service
IssueLinks::ListService.new(issue, current_user)
end
def create_service
IssueLinks::CreateService.new(issue, current_user, create_params)
end
......
# frozen_string_literal: true
class Projects::WebIdeTerminalsController < Projects::ApplicationController
before_action :authenticate_user!
before_action :build, except: [:check_config, :create]
before_action :authorize_create_web_ide_terminal!
before_action :authorize_read_web_ide_terminal!, except: [:check_config, :create]
before_action :authorize_update_web_ide_terminal!, only: [:cancel, :retry]
def check_config
return respond_422 unless branch_sha
result = ::Ci::WebIdeConfigService.new(project, current_user, sha: branch_sha).execute
if result[:status] == :success
head :ok
else
respond_422
end
end
def show
render_terminal(build)
end
def create
result = ::Ci::CreateWebIdeTerminalService.new(project,
current_user,
ref: params[:branch])
.execute
if result[:status] == :error
render status: :bad_request, json: result[:message]
else
pipeline = result[:pipeline]
current_build = pipeline.builds.last
if current_build
render_terminal(current_build)
else
render status: :bad_request, json: pipeline.errors.full_messages
end
end
end
def cancel
return respond_422 unless build.cancelable?
build.cancel
head :ok
end
def retry
return respond_422 unless build.retryable?
new_build = Ci::Build.retry(build, current_user)
render_terminal(new_build)
end
private
def authorize_create_web_ide_terminal!
return access_denied! unless can?(current_user, :create_web_ide_terminal, project)
end
def authorize_read_web_ide_terminal!
authorize_build_ability!(:read_web_ide_terminal)
end
def authorize_update_web_ide_terminal!
authorize_build_ability!(:update_web_ide_terminal)
end
def authorize_build_ability!(ability)
return access_denied! unless can?(current_user, ability, build)
end
def build
@build ||= project.builds.find(params[:id])
end
def branch_sha
return unless params[:branch].present?
project.commit(params[:branch])&.id
end
def render_terminal(current_build)
render json: WebIdeTerminalSerializer
.new(project: project, current_user: current_user)
.represent(current_build)
end
end
# frozen_string_literal: true
module EE
module IdeHelper
extend ::Gitlab::Utils::Override
override :ide_data
def ide_data
super.merge({
"ee-web-terminal-svg-path" => image_path('illustrations/web-ide_promotion.svg'),
"ee-ci-yaml-help-path" => help_page_path('ci/yaml/README.md'),
"ee-ci-runners-help-path" => help_page_path('ci/runners/README.md'),
"ee-web-terminal-help-path" => help_page_path('user/project/web_ide/index.md', anchor: 'client-side-evaluation')
})
end
end
end
::IdeHelper.prepend(::EE::IdeHelper)
......@@ -14,6 +14,44 @@ module EE
}.merge(super)
end
def epics_sort_options_hash
{
sort_value_created_date => sort_title_created_date,
sort_value_oldest_created => sort_title_created_date,
sort_value_recently_created => sort_title_created_date,
sort_value_oldest_updated => sort_title_recently_updated,
sort_value_recently_updated => sort_title_recently_updated,
sort_value_start_date_later => sort_title_start_date,
sort_value_start_date_soon => sort_title_start_date,
sort_value_end_date_later => sort_title_end_date,
sort_value_end_date => sort_title_end_date
}
end
# This method is used to find the opposite ordering parameter for the sort button in the UI.
# Hash key is the descending sorting order and the sort value is the opposite of it for the same field.
# For example: created_at_asc => created_at_desc
def epics_ordering_options_hash
{
sort_value_oldest_created => sort_value_recently_created,
sort_value_oldest_updated => sort_value_recently_updated,
sort_value_start_date_soon => sort_value_start_date_later,
sort_value_end_date => sort_value_end_date_later
}
end
# Creates a button with the opposite ordering for the current field in UI.
def sort_order_button(sort)
opposite_sorting_param = epics_ordering_options_hash[sort] || epics_ordering_options_hash.key(sort)
sort_icon = sort.end_with?('desc') ? 'sort-highest' : 'sort-lowest'
link_to sprite_icon(sort_icon, size: 16),
page_filter_path(sort: opposite_sorting_param, label: true),
class: "btn btn-default has-tooltip qa-reverse-sort btn-sort-direction",
title: _("Sort direction")
end
def sort_title_start_date
s_('SortOptions|Start date')
end
......@@ -42,6 +80,10 @@ module EE
'end_date_asc'
end
def sort_value_end_date_later
'end_date_desc'
end
def sort_value_less_weight
'weight_asc'
end
......
......@@ -56,6 +56,14 @@ module EE
reorder(::Gitlab::Database.nulls_last_order('end_date'), 'id DESC')
end
scope :order_end_date_desc, -> do
reorder(::Gitlab::Database.nulls_last_order('end_date', 'DESC'), 'id DESC')
end
scope :order_start_date_desc, -> do
reorder(::Gitlab::Database.nulls_last_order('start_date', 'DESC'), 'id DESC')
end
def etag_caching_enabled?
true
end
......@@ -104,7 +112,9 @@ module EE
case method.to_s
when 'start_or_end_date' then order_start_or_end_date_asc
when 'start_date_asc' then order_start_date_asc
when 'start_date_desc' then order_start_date_desc
when 'end_date_asc' then order_end_date_asc
when 'end_date_desc' then order_end_date_desc
else
super
end
......
......@@ -92,7 +92,7 @@ class License < ActiveRecord::Base
prometheus_alerts
operations_dashboard
tracing
webide_terminal
web_ide_terminal
].freeze
# List all features available for early adopters,
......
# frozen_string_literal: true
class WebIdeTerminal
include ::Gitlab::Routing
attr_reader :build, :project
delegate :id, :status, to: :build
def initialize(build)
@build = build
@project = build.project
end
def show_path
web_ide_terminal_route_generator(:show)
end
def retry_path
web_ide_terminal_route_generator(:retry)
end
def cancel_path
web_ide_terminal_route_generator(:cancel)
end
def terminal_path
terminal_project_job_path(project, build, format: :ws)
end
private
def web_ide_terminal_route_generator(action)
url_for(action: action,
controller: 'projects/web_ide_terminals',
namespace_id: project.namespace.to_param,
project_id: project.to_param,
id: build.id,
only_path: true)
end
end
......@@ -11,6 +11,23 @@ module EE
prevent :update_build
end
condition(:is_web_ide_terminal, scope: :subject) do
@subject.pipeline.webide?
end
rule { is_web_ide_terminal & can?(:create_web_ide_terminal) & (admin | owner_of_job) }.policy do
enable :read_web_ide_terminal
enable :update_web_ide_terminal
end
rule { is_web_ide_terminal & ~can?(:update_web_ide_terminal) }.policy do
prevent :create_build_terminal
end
rule { can?(:update_web_ide_terminal) & terminal }.policy do
enable :create_build_terminal
end
private
alias_method :current_user, :user
......
......@@ -203,7 +203,7 @@ module EE
end
condition(:web_ide_terminal_available) do
@subject.feature_available?(:webide_terminal)
@subject.feature_available?(:web_ide_terminal)
end
rule { web_ide_terminal_available & can?(:create_pipeline) & can?(:maintainer_access) }.enable :create_web_ide_terminal
......
......@@ -66,7 +66,7 @@ module EE
end
expose :managed_licenses_path do |merge_request|
api_v4_projects_managed_licenses_path(id: merge_request.source_project.id)
api_v4_projects_managed_licenses_path(id: merge_request.target_project.id)
end
expose :can_manage_licenses do |merge_request|
......
# frozen_string_literal: true
class WebIdeTerminalEntity < Grape::Entity
expose :id
expose :status
expose :show_path
expose :cancel_path
expose :retry_path
expose :terminal_path
end
# frozen_string_literal: true
class WebIdeTerminalSerializer < BaseSerializer
entity WebIdeTerminalEntity
def represent(resource, opts = {})
resource = WebIdeTerminal.new(resource) if resource.is_a?(Ci::Build)
super
end
end
# frozen_string_literal: true
module Ci
class CreateWebideTerminalService < ::BaseService
class CreateWebIdeTerminalService < ::BaseService
include ::Gitlab::Utils::StrongMemoize
TerminalCreationError = Class.new(StandardError)
......@@ -62,7 +62,7 @@ module Ci
end
def load_terminal_config!
result = ::Ci::WebideConfigService.new(project, current_user, sha: sha).execute
result = ::Ci::WebIdeConfigService.new(project, current_user, sha: sha).execute
raise TerminalCreationError, result[:message] if result[:status] != :success
@terminal = result[:terminal]
......
# frozen_string_literal: true
module Ci
class WebideConfigService < ::BaseService
class WebIdeConfigService < ::BaseService
include ::Gitlab::Utils::StrongMemoize
ValidationError = Class.new(StandardError)
......@@ -37,12 +37,12 @@ module Ci
end
def load_config!
@config = Gitlab::Webide::Config.new(config_content)
@config = Gitlab::WebIde::Config.new(config_content)
unless @config.valid?
raise ValidationError, @config.errors.first
end
rescue Gitlab::Webide::Config::ConfigError => e
rescue Gitlab::WebIde::Config::ConfigError => e
raise ValidationError, e.message
end
......
......@@ -25,7 +25,7 @@ module EE
epic_param = params.delete(:epic)
if epic_param
EpicIssues::CreateService.new(epic_param, current_user, { target_issue: issue }).execute
EpicIssues::CreateService.new(epic_param, current_user, { target_issuable: issue }).execute
else
link = EpicIssue.find_by(issue_id: issue.id) # rubocop: disable CodeReuse/ActiveRecord
......
......@@ -3,7 +3,7 @@ module EpicIssues
private
# rubocop: disable CodeReuse/ActiveRecord
def relate_issues(referenced_issue)
def relate_issuables(referenced_issue)
link = EpicIssue.find_or_initialize_by(issue: referenced_issue)
affected_epics = [issuable]
......@@ -41,17 +41,17 @@ module EpicIssues
{ group: issuable.group }
end
def linkable_issues(issues)
def linkable_issuables(issues)
@linkable_issues ||= begin
return [] unless can?(current_user, :admin_epic, issuable.group)
issues.select do |issue|
issuable_group_descendants.include?(issue.project.group) && !previous_related_issues.include?(issue)
issuable_group_descendants.include?(issue.project.group) && !previous_related_issuables.include?(issue)
end
end
end
def previous_related_issues
def previous_related_issuables
@related_issues ||= issuable.issues.to_a
end
......
......@@ -2,7 +2,7 @@ module EpicIssues
class ListService < IssuableLinks::ListService
private
def issues
def child_issuables
return [] unless issuable&.group&.feature_available?(:epics)
issuable.issues_readable_by(current_user)
......
......@@ -11,58 +11,62 @@ module IssuableLinks
# otherwise create issue links for the issues which
# are still not assigned and return success message.
if render_conflict_error?
return error('Issue(s) already assigned', 409)
return error(issuables_assigned_message, 409)
end
if render_not_found_error?
return error('No Issue found for given params', 404)
return error(issuables_not_found_message, 404)
end
create_issue_links
create_links
success
end
private
def render_conflict_error?
referenced_issues.present? && (referenced_issues - previous_related_issues).empty?
referenced_issuables.present? && (referenced_issuables - previous_related_issuables).empty?
end
def render_not_found_error?
linkable_issues(referenced_issues).empty?
linkable_issuables(referenced_issuables).empty?
end
def create_issue_links
issues = linkable_issues(referenced_issues)
def create_links
objects = linkable_issuables(referenced_issuables)
issues.each do |referenced_issue|
relate_issues(referenced_issue) do |params|
create_notes(referenced_issue, params)
objects.each do |referenced_object|
relate_issuables(referenced_object) do |params|
create_notes(referenced_object, params)
end
end
end
def referenced_issues
@referenced_issues ||= begin
target_issue = params[:target_issue]
def referenced_issuables
@referenced_issuables ||= begin
target_issuable = params[:target_issuable]
if params[:issue_references].present?
extract_issues_from_references
elsif target_issue
[target_issue]
if params[:issuable_references].present?
extract_references
elsif target_issuable
[target_issuable]
else
[]
end
end
end
def extract_issues_from_references
issue_references = params[:issue_references]
text = issue_references.join(' ')
def extract_references
issuable_references = params[:issuable_references]
text = issuable_references.join(' ')
extractor = Gitlab::ReferenceExtractor.new(issuable.project, @current_user)
extractor = Gitlab::ReferenceExtractor.new(issuable.project, current_user)
extractor.analyze(text, extractor_context)
references(extractor)
end
def references(extractor)
extractor.issues
end
......@@ -70,16 +74,24 @@ module IssuableLinks
{}
end
def linkable_issues(issues)
def linkable_issuables(objects)
raise NotImplementedError
end
def previous_related_issues
def previous_related_issuables
raise NotImplementedError
end
def relate_issues(referenced_issue)
def relate_issuables(referenced_object)
raise NotImplementedError
end
def issuables_assigned_message
'Issue(s) already assigned'
end
def issuables_not_found_message
'No Issue found for given params'
end
end
end
......@@ -9,29 +9,33 @@ module IssuableLinks
end
def execute
issues.map do |referenced_issue|
to_hash(referenced_issue)
child_issuables.map do |referenced_object|
to_hash(referenced_object)
end
end
private
def relation_path(issue)
def relation_path(object)
raise NotImplementedError
end
def reference(issue)
issue.to_reference(issuable.project)
def reference(object)
object.to_reference(issuable.project)
end
def to_hash(issue)
def issuable_path(object)
project_issue_path(object.project, object.iid)
end
def to_hash(object)
{
id: issue.id,
title: issue.title,
state: issue.state,
reference: reference(issue),
path: project_issue_path(issue.project, issue.iid),
relation_path: relation_path(issue)
id: object.id,
title: object.title,
state: object.state,
reference: reference(object),
path: issuable_path(object),
relation_path: relation_path(object)
}
end
end
......
module IssueLinks
class CreateService < IssuableLinks::CreateService
def relate_issues(referenced_issue)
def relate_issuables(referenced_issue)
link = IssueLink.create(source: issuable, target: referenced_issue)
yield if link.persisted?
end
def linkable_issues(issues)
@linkable_issues ||= begin
def linkable_issuables(issues)
@linkable_issuables ||= begin
issues.select { |issue| can?(current_user, :admin_issue_link, issue) }
end
end
......@@ -17,7 +17,7 @@ module IssueLinks
SystemNoteService.relate_issue(referenced_issue, issuable, current_user)
end
def previous_related_issues
def previous_related_issuables
@related_issues ||= issuable.related_issues(current_user).to_a
end
end
......
......@@ -4,7 +4,7 @@ module IssueLinks
private
def issues
def child_issuables
issuable.related_issues(current_user, preload: { project: :namespace })
end
......
= render partial: "ide/show"
- sorted_by = sort_options_hash[@sort]
- sorted_by = epics_sort_options_hash[@sort]
.dropdown.inline.prepend-left-10
.btn-group
%button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown' } }
= sorted_by
= icon('chevron-down')
%ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort
%li
= sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_created_date, label: true), sorted_by)
= sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_recently_created, label: true), sorted_by)
= sortable_item(sort_title_recently_updated, page_filter_path(sort: sort_value_recently_updated, label: true), sorted_by)
= sortable_item(sort_title_start_date, page_filter_path(sort: sort_value_start_date, label: true), sorted_by)
= sortable_item(sort_title_start_date, page_filter_path(sort: sort_value_start_date_soon, label: true), sorted_by)
= sortable_item(sort_title_end_date, page_filter_path(sort: sort_value_end_date, label: true), sorted_by)
= sort_order_button(@sort)
---
title: Fix issue board api with special milestones
merge_request: 8653
author:
type: fixed
---
title: Add sort direction button with sort dropdown for Epics and Roadmap
merge_request:
author:
type: changed
---
title: Fixed license managment path in MR widget for fork cases
merge_request: 8700
author:
type: fixed
---
title: Added web terminals to Web IDE
merge_request: 7386
author:
type: added
......@@ -20,6 +20,17 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
get 'epics'
end
end
resources :web_ide_terminals, path: :ide_terminals, only: [:create, :show], constraints: { id: /\d+/, format: :json } do
member do
post :cancel
post :retry
end
collection do
post :check_config
end
end
end
end
end
class AddEpicsSortToUserPreference < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def change
add_column :user_preferences, :epics_sort, :string
end
end
......@@ -89,7 +89,7 @@ module API
issue = Issue.find(params[:issue_id])
create_params = { target_issue: issue }
create_params = { target_issuable: issue }
result = ::EpicIssues::CreateService.new(epic, current_user, create_params).execute
......
......@@ -35,7 +35,7 @@ module API
target_issue = find_project_issue(declared_params[:target_issue_iid],
declared_params[:target_project_id])
create_params = { target_issue: target_issue }
create_params = { target_issuable: target_issue }
result = ::IssueLinks::CreateService
.new(source_issue, current_user, create_params)
......
......@@ -93,17 +93,22 @@ module EE
extend ActiveSupport::Concern
prepended do
def scoped_issue_available?(board)
board.parent.feature_available?(:scoped_issue_board)
end
# Default filtering configuration
expose :name
expose :group
expose :milestone, using: ::API::Entities::Milestone, if: ->(board, _) { scoped_issue_available?(board) }
expose :assignee, using: ::API::Entities::UserBasic, if: ->(board, _) { scoped_issue_available?(board) }
expose :labels, using: ::API::Entities::LabelBasic, if: ->(board, _) { scoped_issue_available?(board) }
expose :weight, if: ->(board, _) { scoped_issue_available?(board) }
with_options if: ->(board, _) { board.parent.feature_available?(:scoped_issue_board) } do
expose :milestone do |board|
if board.milestone.is_a?(Milestone)
::API::Entities::Milestone.represent(board.milestone)
else
SpecialBoardFilter.represent(board.milestone)
end
end
expose :assignee, using: ::API::Entities::UserBasic
expose :labels, using: ::API::Entities::LabelBasic
expose :weight
end
end
end
......@@ -213,6 +218,10 @@ module EE
expose :target, as: :target_issue, using: ::API::Entities::IssueBasic
end
class SpecialBoardFilter < Grape::Entity
expose :title
end
class Approvals < Grape::Entity
expose :user, using: ::API::Entities::UserBasic
end
......
# frozen_string_literal: true
module Gitlab
module Webide
module WebIde
#
# Base GitLab Webide Configuration facade
# Base GitLab WebIde Configuration facade
#
class Config
ConfigError = Class.new(StandardError)
......
# frozen_string_literal: true
module Gitlab
module Webide
module WebIde
class Config
module Entry
##
# This class represents a global entry - root Entry for entire
# GitLab Webide Configuration file.
# GitLab WebIde Configuration file.
#
class Global < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Configurable
......
# frozen_string_literal: true
module Gitlab
module Webide
module WebIde
class Config
module Entry
##
......
......@@ -2,13 +2,13 @@
require 'spec_helper'
describe Ci::CreateWebideTerminalService do
describe Ci::CreateWebIdeTerminalService do
set(:project) { create(:project, :repository) }
set(:user) { create(:user) }
let(:ref) { 'master' }
before do
stub_licensed_features(webide_terminal: true)
stub_licensed_features(web_ide_terminal: true)
end
describe '#execute' do
......
......@@ -2,7 +2,7 @@
require 'spec_helper'
describe Ci::WebideConfigService do
describe Ci::WebIdeConfigService do
set(:project) { create(:project, :repository) }
set(:user) { create(:user) }
let(:sha) { 'sha' }
......
......@@ -65,7 +65,7 @@ describe Groups::EpicIssuesController do
subject do
reference = [issue.to_reference(full: true)]
post :create, group_id: group, epic_id: epic.to_param, issue_references: reference
post :create, group_id: group, epic_id: epic.to_param, issuable_references: reference
end
it_behaves_like 'unlicensed epics action'
......@@ -81,7 +81,7 @@ describe Groups::EpicIssuesController do
list_service_response = EpicIssues::ListService.new(epic, user).execute
expect(response).to have_gitlab_http_status(200)
expect(json_response).to eq('message' => nil, 'issues' => list_service_response.as_json)
expect(json_response).to eq('message' => nil, 'issuables' => list_service_response.as_json)
end
it 'creates a new EpicIssue record' do
......
......@@ -57,12 +57,39 @@ describe Groups::EpicsController do
expect(response).to have_gitlab_http_status(200)
end
context 'when there is no logged in user' do
it 'stores sorting param in a cookie' do
group.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
sign_out(user)
get :index, group_id: group, sort: 'start_date_asc'
expect(cookies['epic_sort']).to eq('start_date_asc')
expect(response).to have_gitlab_http_status(200)
end
end
context 'when there is a logged in user' do
context 'when epics_sort is nil' do
it 'stores sorting param in user preferences' do
get :index, group_id: group, sort: 'start_date_asc'
expect(user.user_preference.epics_sort).to eq('start_date_asc')
expect(response).to have_gitlab_http_status(200)
end
end
context 'when epics_sort is present' do
it 'update epics_sort with current value' do
user.user_preference.update(epics_sort: 'created_desc')
get :index, group_id: group, sort: 'start_date_asc'
expect(user.reload.user_preference.epics_sort).to eq('start_date_asc')
expect(response).to have_gitlab_http_status(200)
end
end
end
context 'with page param' do
let(:last_page) { group.epics.page.total_pages }
......
......@@ -31,12 +31,39 @@ describe Groups::RoadmapController do
expect(response).to have_gitlab_http_status(200)
end
it 'stores sorting param in a cookie' do
context 'when there is no logged user' do
it 'stores epics sorting param in a cookie' do
group.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
sign_out(user)
get :show, group_id: group, sort: 'start_date_asc'
expect(cookies['epic_sort']).to eq('start_date_asc')
expect(response).to have_gitlab_http_status(200)
end
end
context 'when there is a user logged in' do
context 'when epics_sort is nil' do
it 'stores epics sorting param in user preference' do
get :show, group_id: group, sort: 'start_date_asc'
expect(response).to have_gitlab_http_status(200)
expect(user.reload.user_preference.epics_sort).to eq('start_date_asc')
end
end
context 'when epics_sort is present' do
it 'update epics_sort with current value' do
user.user_preference.update(epics_sort: 'created_desc')
get :show, group_id: group, sort: 'start_date_asc'
expect(user.reload.user_preference.epics_sort).to eq('start_date_asc')
expect(response).to have_gitlab_http_status(200)
end
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Projects::WebIdeTerminalsController do
let(:owner) { create(:owner) }
let(:admin) { create(:admin) }
let(:maintainer) { create(:user) }
let(:developer) { create(:user) }
let(:reporter) { create(:user) }
let(:guest) { create(:user) }
let(:project) { create(:project, :private, :repository, namespace: owner.namespace) }
let(:pipeline) { create(:ci_pipeline, project: project, source: :webide, config_source: :webide_source, user: user) }
let(:job) { create(:ci_build, pipeline: pipeline, user: user, project: project) }
let(:user) { maintainer }
before do
stub_licensed_features(web_ide_terminal: true)
project.add_maintainer(maintainer)
project.add_developer(developer)
project.add_reporter(reporter)
project.add_guest(guest)
sign_in(user)
end
shared_examples 'terminal access rights' do
context 'with admin' do
let(:user) { admin }
it 'returns 200' do
expect(response).to have_gitlab_http_status(200)
end
end
context 'with owner' do
let(:user) { owner }
it 'returns 200' do
expect(response).to have_gitlab_http_status(200)
end
end
context 'with maintainer' do
let(:user) { maintainer }
it 'returns 200' do
expect(response).to have_gitlab_http_status(200)
end
end
context 'with developer' do
let(:user) { developer }
it 'returns 404' do
expect(response).to have_gitlab_http_status(404)
end
end
context 'with reporter' do
let(:user) { reporter }
it 'returns 404' do
expect(response).to have_gitlab_http_status(404)
end
end
context 'with guest' do
let(:user) { guest }
it 'returns 404' do
expect(response).to have_gitlab_http_status(404)
end
end
context 'with non member' do
let(:user) { create(:user) }
it 'returns 404' do
expect(response).to have_gitlab_http_status(404)
end
end
end
shared_examples 'when pipeline is not from a webide source' do
context 'with admin' do
let(:user) { admin }
let(:pipeline) { create(:ci_pipeline, project: project, source: :chat, user: user) }
it 'returns 404' do
expect(response).to have_gitlab_http_status(404)
end
end
end
describe 'GET show' do
before do
get(:show, namespace_id: project.namespace.to_param, project_id: project, id: job.id)
end
it_behaves_like 'terminal access rights'
it_behaves_like 'when pipeline is not from a webide source'
end
describe 'POST check_config' do
let(:result) { { status: :success } }
before do
allow_any_instance_of(::Ci::WebIdeConfigService)
.to receive(:execute).and_return(result)
post :check_config, namespace_id: project.namespace.to_param,
project_id: project.to_param,
branch: 'master'
end
it_behaves_like 'terminal access rights'
context 'when invalid config file' do
let(:user) { admin }
let(:result) { { status: :error } }
it 'returns 422' do
expect(response).to have_gitlab_http_status(422)
end
end
end
describe 'POST create' do
let(:branch) { 'master' }
subject do
post :create, namespace_id: project.namespace.to_param,
project_id: project.to_param,
branch: branch
end
context 'access rights' do
let(:build) { create(:ci_build, project: project) }
let(:pipeline) { build.pipeline }
before do
allow_any_instance_of(::Ci::CreateWebIdeTerminalService)
.to receive(:execute).and_return(status: :success, pipeline: pipeline)
subject
end
it_behaves_like 'terminal access rights'
end
context 'when branch does not exist' do
let(:user) { admin }
let(:branch) { 'foobar' }
it 'returns 400' do
subject
expect(response).to have_gitlab_http_status(400)
end
end
context 'when there is an error creating the job' do
let(:user) { admin }
it 'returns 400' do
allow_any_instance_of(::Ci::CreateWebIdeTerminalService)
.to receive(:execute).and_return(status: :error, message: 'foobar')
subject
expect(response).to have_gitlab_http_status(400)
end
end
end
describe 'POST cancel' do
let(:job) { create(:ci_build, :running, pipeline: pipeline, user: user, project: project) }
before do
post(:cancel, namespace_id: project.namespace.to_param,
project_id: project.to_param,
id: job.id)
end
it_behaves_like 'terminal access rights'
it_behaves_like 'when pipeline is not from a webide source'
context 'when job is not cancelable' do
let!(:job) { create(:ci_build, :failed, pipeline: pipeline, user: user) }
it 'returns 422' do
expect(response).to have_gitlab_http_status(422)
end
end
end
describe 'POST retry' do
let(:job) { create(:ci_build, :failed, pipeline: pipeline, user: user, project: project) }
before do
post(:retry, namespace_id: project.namespace.to_param,
project_id: project.to_param,
id: job.id)
end
it_behaves_like 'terminal access rights'
it_behaves_like 'when pipeline is not from a webide source'
context 'when job is not retryable' do
let!(:job) { create(:ci_build, :running, pipeline: pipeline, user: user) }
it 'returns 422' do
expect(response).to have_gitlab_http_status(422)
end
end
end
end
......@@ -43,23 +43,23 @@ describe 'epics list', :js do
end
end
it 'sorts by end_date ASC by default' do
expect(page).to have_button('Due date')
it 'sorts by created_at DESC by default' do
expect(page).to have_button('Created date')
page.within('.content-wrapper .content') do
expect(find('.top-area')).to have_content('All 3')
page.within(".issuable-list") do
page.within("li:nth-child(1)") do
expect(page).to have_content(epic1.title)
expect(page).to have_content(epic3.title)
end
page.within("li:nth-child(2)") do
expect(page).to have_content(epic3.title)
expect(page).to have_content(epic2.title)
end
page.within("li:nth-child(3)") do
expect(page).to have_content(epic2.title)
expect(page).to have_content(epic1.title)
end
end
end
......@@ -67,7 +67,7 @@ describe 'epics list', :js do
it 'sorts by the selected value and stores the selection for epic list & roadmap' do
page.within('.epics-other-filters') do
click_button 'Due date'
click_button 'Created date'
sort_options = find('ul.dropdown-menu-sort li').all('a').collect(&:text)
expect(sort_options[0]).to eq('Created date')
......
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import RightPane from '~/ide/components/panes/right.vue';
import EERightPane from 'ee/ide/components/panes/right.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('IDE EERightPane', () => {
let wrapper;
let terminalState;
const factory = () => {
const store = new Vuex.Store({
modules: {
terminal: {
namespaced: true,
state: terminalState,
},
},
});
wrapper = shallowMount(localVue.extend(EERightPane), { localVue, store });
};
beforeEach(() => {
terminalState = {};
});
afterEach(() => {
wrapper.destroy();
});
it('adds terminal tab', () => {
terminalState.isVisible = true;
factory();
expect(wrapper.find(RightPane).props('extensionTabs')).toEqual([
jasmine.objectContaining({
show: true,
title: 'Terminal',
}),
]);
});
it('hides terminal tab when not visible', () => {
terminalState.isVisible = false;
factory();
expect(wrapper.find(RightPane).props('extensionTabs')).toEqual([
jasmine.objectContaining({
show: false,
title: 'Terminal',
}),
]);
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlLoadingIcon } from '@gitlab/ui';
import { TEST_HOST } from 'spec/test_constants';
import TerminalEmptyState from 'ee/ide/components/terminal/empty_state.vue';
const TEST_HELP_PATH = `${TEST_HOST}/help/test`;
const TEST_PATH = `${TEST_HOST}/home.png`;
const TEST_HTML_MESSAGE = 'lorem <strong>ipsum</strong>';
describe('EE IDE TerminalEmptyState', () => {
let wrapper;
describe('TerminalEmptyState', () => {
const factory = (options = {}) => {
const localVue = createLocalVue();
return shallowMount(TerminalEmptyState, {
wrapper = shallowMount(localVue.extend(TerminalEmptyState), {
sync: false,
localVue,
...options,
});
};
afterEach(() => {
wrapper.destroy();
});
it('does not show illustration, if no path specified', () => {
const wrapper = factory();
factory();
expect(wrapper.find('.svg-content').exists()).toBe(false);
});
it('shows illustration with path', () => {
const wrapper = factory({
factory({
propsData: {
illustrationPath: TEST_PATH,
},
......@@ -32,4 +42,70 @@ describe('TerminalEmptyState', () => {
expect(img.exists()).toBe(true);
expect(img.attributes('src')).toEqual(TEST_PATH);
});
it('when loading, shows loading icon', () => {
factory({
propsData: {
isLoading: true,
},
});
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
it('when not loading, does not show loading icon', () => {
factory({
propsData: {
isLoading: false,
},
});
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
});
describe('when valid', () => {
let button;
beforeEach(() => {
factory({
propsData: {
isLoading: false,
isValid: true,
helpPath: TEST_HELP_PATH,
},
});
button = wrapper.find('button');
});
it('shows button', () => {
expect(button.text()).toEqual('Start Web Terminal');
expect(button.attributes('disabled')).toBeFalsy();
});
it('emits start when button is clicked', () => {
expect(wrapper.emitted().start).toBeFalsy();
button.trigger('click');
expect(wrapper.emitted().start.length).toBe(1);
});
it('shows help path link', () => {
expect(wrapper.find('a').attributes('href')).toEqual(TEST_HELP_PATH);
});
});
it('when not valid, shows disabled button and message', () => {
factory({
propsData: {
isLoading: false,
isValid: false,
message: TEST_HTML_MESSAGE,
},
});
expect(wrapper.find('button').attributes('disabled')).not.toBe(null);
expect(wrapper.find('.bs-callout').element.innerHTML).toEqual(TEST_HTML_MESSAGE);
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { TEST_HOST } from 'spec/test_constants';
import TerminalView from 'ee/ide/components/terminal/view.vue';
import TerminalEmptyState from 'ee/ide/components/terminal/empty_state.vue';
import TerminalView from 'ee/ide/components/terminal/view.vue';
const TEST_HELP_PATH = `${TEST_HOST}/help`;
const TEST_SVG_PATH = `${TEST_HOST}/illustration.svg`;
const localVue = createLocalVue();
localVue.use(Vuex);
describe('TerminalView', () => {
describe('EE IDE TerminalView', () => {
let state;
let actions;
let getters;
let wrapper;
const factory = () => {
const store = new Vuex.Store({
state: {
emptyStateSvgPath: TEST_SVG_PATH,
modules: {
terminal: {
namespaced: true,
state,
actions,
getters,
},
},
});
return shallowMount(TerminalView, { localVue, store });
wrapper = shallowMount(localVue.extend(TerminalView), { localVue, store });
};
beforeEach(() => {
state = {
isShowSplash: true,
paths: {
webTerminalHelpPath: TEST_HELP_PATH,
webTerminalSvgPath: TEST_SVG_PATH,
},
};
actions = {
hideSplash: jasmine.createSpy('hideSplash'),
};
getters = {
allCheck: () => ({
isLoading: false,
isValid: false,
message: 'bad',
}),
};
});
afterEach(() => {
wrapper.destroy();
});
it('renders empty state', () => {
const wrapper = factory();
factory();
expect(wrapper.find(TerminalEmptyState).props()).toEqual({
helpPath: TEST_HELP_PATH,
illustrationPath: TEST_SVG_PATH,
...getters.allCheck(),
});
});
it('hides splash when started', () => {
factory();
expect(actions.hideSplash).not.toHaveBeenCalled();
wrapper.find(TerminalEmptyState).vm.$emit('start');
expect(actions.hideSplash).toHaveBeenCalled();
});
it('shows Web Terminal when started', () => {
state.isShowSplash = false;
factory();
expect(wrapper.find(TerminalEmptyState).exists()).toBe(false);
expect(wrapper.text()).toContain('Web Terminal');
});
});
import { createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { SET_BRANCH_WORKING_REFERENCE } from '~/ide/stores/mutation_types';
import { TEST_HOST } from 'spec/test_constants';
import terminalModule from 'ee/ide/stores/modules/terminal';
import extendStore from 'ee/ide/stores/extend';
const TEST_DATASET = {
eeCiYamlHelpPath: `${TEST_HOST}/ci/yaml/help`,
eeCiRunnersHelpPath: `${TEST_HOST}/ci/runners/help`,
eeWebTerminalHelpPath: `${TEST_HOST}/web/terminal/help`,
eeWebTerminalSvgPath: `${TEST_HOST}/web/terminal/svg`,
};
const localVue = createLocalVue();
localVue.use(Vuex);
describe('ee/ide/stores/extend', () => {
let store;
beforeEach(() => {
const el = document.createElement('div');
Object.assign(el.dataset, TEST_DATASET);
store = new Vuex.Store({
mutations: {
[SET_BRANCH_WORKING_REFERENCE]: () => {},
},
});
spyOn(store, 'registerModule');
spyOn(store, 'dispatch');
store = extendStore(store, el);
});
it('registers terminal module', () => {
expect(store.registerModule).toHaveBeenCalledWith('terminal', terminalModule());
});
it('dispatches terminal/setPaths', () => {
expect(store.dispatch).toHaveBeenCalledWith('terminal/setPaths', {
ciYamlHelpPath: TEST_DATASET.eeCiYamlHelpPath,
ciRunnersHelpPath: TEST_DATASET.eeCiRunnersHelpPath,
webTerminalHelpPath: TEST_DATASET.eeWebTerminalHelpPath,
webTerminalSvgPath: TEST_DATASET.eeWebTerminalSvgPath,
});
});
it(`dispatches terminal/init on ${SET_BRANCH_WORKING_REFERENCE}`, () => {
store.dispatch.calls.reset();
store.commit(SET_BRANCH_WORKING_REFERENCE);
expect(store.dispatch).toHaveBeenCalledWith('terminal/init');
});
});
import MockAdapter from 'axios-mock-adapter';
import httpStatus from '~/lib/utils/http_status';
import axios from '~/lib/utils/axios_utils';
import testAction from 'spec/helpers/vuex_action_helper';
import { TEST_HOST } from 'spec/test_constants';
import { CHECK_CONFIG, CHECK_RUNNERS, RETRY_RUNNERS_INTERVAL } from 'ee/ide/constants';
import * as mutationTypes from 'ee/ide/stores/modules/terminal/mutation_types';
import * as messages from 'ee/ide/stores/modules/terminal/messages';
import * as actions from 'ee/ide/stores/modules/terminal/actions/checks';
const TEST_YAML_HELP_PATH = `${TEST_HOST}/test/yaml/help`;
const TEST_RUNNERS_HELP_PATH = `${TEST_HOST}/test/runners/help`;
describe('EE IDE store terminal check actions', () => {
let mock;
let state;
beforeEach(() => {
mock = new MockAdapter(axios);
state = {
paths: {
ciYamlHelpPath: TEST_YAML_HELP_PATH,
ciRunnersHelpPath: TEST_RUNNERS_HELP_PATH,
},
checks: {
config: { isLoading: true },
},
};
jasmine.clock().install();
});
afterEach(() => {
mock.restore();
jasmine.clock().uninstall();
});
describe('requestConfigCheck', () => {
it('handles request loading', done => {
testAction(
actions.requestConfigCheck,
null,
{},
[{ type: mutationTypes.REQUEST_CHECK, payload: CHECK_CONFIG }],
[],
done,
);
});
});
describe('receiveConfigCheckSuccess', () => {
it('handles successful response', done => {
testAction(
actions.receiveConfigCheckSuccess,
null,
{},
[
{ type: mutationTypes.SET_VISIBLE, payload: true },
{ type: mutationTypes.RECEIVE_CHECK_SUCCESS, payload: CHECK_CONFIG },
],
[],
done,
);
});
});
describe('receiveConfigCheckError', () => {
it('handles error response', done => {
const status = httpStatus.UNPROCESSABLE_ENTITY;
const payload = { response: { status } };
testAction(
actions.receiveConfigCheckError,
payload,
state,
[
{
type: mutationTypes.SET_VISIBLE,
payload: true,
},
{
type: mutationTypes.RECEIVE_CHECK_ERROR,
payload: {
type: CHECK_CONFIG,
message: messages.configCheckError(status, TEST_YAML_HELP_PATH),
},
},
],
[],
done,
);
});
[httpStatus.FORBIDDEN, httpStatus.NOT_FOUND].forEach(status => {
it(`hides tab, when status is ${status}`, done => {
const payload = { response: { status } };
testAction(
actions.receiveConfigCheckError,
payload,
state,
[
{
type: mutationTypes.SET_VISIBLE,
payload: false,
},
jasmine.objectContaining({ type: mutationTypes.RECEIVE_CHECK_ERROR }),
],
[],
done,
);
});
});
});
describe('fetchConfigCheck', () => {
it('dispatches request and receive', done => {
testAction(
actions.fetchConfigCheck,
null,
{},
[],
[{ type: 'requestConfigCheck' }, { type: 'receiveConfigCheckSuccess' }],
done,
);
});
});
describe('requestRunnersCheck', () => {
it('handles request loading', done => {
testAction(
actions.requestRunnersCheck,
null,
{},
[{ type: mutationTypes.REQUEST_CHECK, payload: CHECK_RUNNERS }],
[],
done,
);
});
});
describe('receiveRunnersCheckSuccess', () => {
it('handles successful response, with data', done => {
const payload = [{}];
testAction(
actions.receiveRunnersCheckSuccess,
payload,
state,
[{ type: mutationTypes.RECEIVE_CHECK_SUCCESS, payload: CHECK_RUNNERS }],
[],
done,
);
});
it('handles successful response, with empty data', done => {
const commitPayload = {
type: CHECK_RUNNERS,
message: messages.runnersCheckEmpty(TEST_RUNNERS_HELP_PATH),
};
testAction(
actions.receiveRunnersCheckSuccess,
[],
state,
[{ type: mutationTypes.RECEIVE_CHECK_ERROR, payload: commitPayload }],
[{ type: 'retryRunnersCheck' }],
done,
);
});
});
describe('receiveRunnersCheckError', () => {
it('dispatches handle with message', done => {
const commitPayload = {
type: CHECK_RUNNERS,
message: messages.UNEXPECTED_ERROR_RUNNERS,
};
testAction(
actions.receiveRunnersCheckError,
null,
{},
[{ type: mutationTypes.RECEIVE_CHECK_ERROR, payload: commitPayload }],
[],
done,
);
});
});
describe('retryRunnersCheck', () => {
it('dispatches fetch again after timeout', () => {
const dispatch = jasmine.createSpy('dispatch');
actions.retryRunnersCheck({ dispatch, state });
expect(dispatch).not.toHaveBeenCalled();
jasmine.clock().tick(RETRY_RUNNERS_INTERVAL + 1);
expect(dispatch).toHaveBeenCalledWith('fetchRunnersCheck', { background: true });
});
it('does not dispatch fetch if config check is error', () => {
const dispatch = jasmine.createSpy('dispatch');
state.checks.config = {
isLoading: false,
isValid: false,
};
actions.retryRunnersCheck({ dispatch, state });
expect(dispatch).not.toHaveBeenCalled();
jasmine.clock().tick(RETRY_RUNNERS_INTERVAL + 1);
expect(dispatch).not.toHaveBeenCalled();
});
});
describe('fetchRunnersCheck', () => {
let rootGetters;
beforeEach(() => {
rootGetters = {
currentProject: { id: 7 },
};
});
it('dispatches request and receive', done => {
mock.onGet(/api\/.*\/projects\/.*\/runners/, { params: { scope: 'active' } }).reply(200, []);
testAction(
actions.fetchRunnersCheck,
{},
rootGetters,
[],
[{ type: 'requestRunnersCheck' }, { type: 'receiveRunnersCheckSuccess', payload: [] }],
done,
);
});
it('does not dispatch request when background is true', done => {
mock.onGet(/api\/.*\/projects\/.*\/runners/, { params: { scope: 'active' } }).reply(200, []);
testAction(
actions.fetchRunnersCheck,
{ background: true },
rootGetters,
[],
[{ type: 'receiveRunnersCheckSuccess', payload: [] }],
done,
);
});
it('dispatches request and receive, when error', done => {
mock.onGet(/api\/.*\/projects\/.*\/runners/, { params: { scope: 'active' } }).reply(500, []);
testAction(
actions.fetchRunnersCheck,
{},
rootGetters,
[],
[
{ type: 'requestRunnersCheck' },
{ type: 'receiveRunnersCheckError', payload: jasmine.any(Error) },
],
done,
);
});
});
});
import testAction from 'spec/helpers/vuex_action_helper';
import * as mutationTypes from 'ee/ide/stores/modules/terminal/mutation_types';
import * as actions from 'ee/ide/stores/modules/terminal/actions/setup';
describe('EE IDE store terminal setup actions', () => {
describe('hideSplash', () => {
it('commits HIDE_SPLASH', done => {
testAction(actions.hideSplash, null, {}, [{ type: mutationTypes.HIDE_SPLASH }], [], done);
});
});
describe('setPaths', () => {
it('commits SET_PATHS', done => {
const paths = {
foo: 'bar',
lorem: 'ipsum',
};
testAction(
actions.setPaths,
paths,
{},
[{ type: mutationTypes.SET_PATHS, payload: paths }],
[],
done,
);
});
});
});
import { CHECK_CONFIG, CHECK_RUNNERS } from 'ee/ide/constants';
import * as getters from 'ee/ide/stores/modules/terminal/getters';
describe('EE IDE store terminal getters', () => {
describe('allCheck', () => {
it('is loading if one check is loading', () => {
const checks = {
[CHECK_CONFIG]: { isLoading: false, isValid: true },
[CHECK_RUNNERS]: { isLoading: true },
};
const result = getters.allCheck({ checks });
expect(result).toEqual({
isLoading: true,
});
});
it('is invalid if one check is invalid', () => {
const message = 'lorem ipsum';
const checks = {
[CHECK_CONFIG]: { isLoading: false, isValid: false, message },
[CHECK_RUNNERS]: { isLoading: false, isValid: true },
};
const result = getters.allCheck({ checks });
expect(result).toEqual({
isLoading: false,
isValid: false,
message,
});
});
it('is valid if all checks are valid', () => {
const checks = {
[CHECK_CONFIG]: { isLoading: false, isValid: true },
[CHECK_RUNNERS]: { isLoading: false, isValid: true },
};
const result = getters.allCheck({ checks });
expect(result).toEqual({
isLoading: false,
isValid: true,
message: '',
});
});
});
});
import _ from 'underscore';
import { sprintf } from '~/locale';
import httpStatus from '~/lib/utils/http_status';
import { TEST_HOST } from 'spec/test_constants';
import * as messages from 'ee/ide/stores/modules/terminal/messages';
const TEST_HELP_URL = `${TEST_HOST}/help`;
describe('EE IDE store terminal messages', () => {
describe('configCheckError', () => {
it('returns job error, with status UNPROCESSABLE_ENTITY', () => {
const result = messages.configCheckError(httpStatus.UNPROCESSABLE_ENTITY, TEST_HELP_URL);
expect(result).toBe(
sprintf(
messages.ERROR_CONFIG,
{
helpStart: `<a href="${_.escape(TEST_HELP_URL)}" target="_blank">`,
helpEnd: '</a>',
},
false,
),
);
});
it('returns permission error, with status FORBIDDEN', () => {
const result = messages.configCheckError(httpStatus.FORBIDDEN, TEST_HELP_URL);
expect(result).toBe(messages.ERROR_PERMISSION);
});
it('returns unexpected error, with unexpected status', () => {
const result = messages.configCheckError(httpStatus.NOT_FOUND, TEST_HELP_URL);
expect(result).toBe(messages.UNEXPECTED_ERROR_CONFIG);
});
});
});
import { CHECK_CONFIG, CHECK_RUNNERS } from 'ee/ide/constants';
import createState from 'ee/ide/stores/modules/terminal/state';
import * as types from 'ee/ide/stores/modules/terminal/mutation_types';
import mutations from 'ee/ide/stores/modules/terminal/mutations';
describe('EE IDE store terminal mutations', () => {
let state;
beforeEach(() => {
state = createState();
});
describe(types.SET_VISIBLE, () => {
it('sets isVisible', () => {
state.isVisible = false;
mutations[types.SET_VISIBLE](state, true);
expect(state.isVisible).toBe(true);
});
});
describe(types.HIDE_SPLASH, () => {
it('sets isShowSplash', () => {
state.isShowSplash = true;
mutations[types.HIDE_SPLASH](state);
expect(state.isShowSplash).toBe(false);
});
});
describe(types.SET_PATHS, () => {
it('sets paths', () => {
const paths = {
test: 'foo',
};
mutations[types.SET_PATHS](state, paths);
expect(state.paths).toBe(paths);
});
});
describe(types.REQUEST_CHECK, () => {
it('sets isLoading for check', () => {
const type = CHECK_CONFIG;
state.checks[type] = {};
mutations[types.REQUEST_CHECK](state, type);
expect(state.checks[type]).toEqual({
isLoading: true,
});
});
});
describe(types.RECEIVE_CHECK_ERROR, () => {
it('sets error for check', () => {
const type = CHECK_RUNNERS;
const message = 'lorem ipsum';
state.checks[type] = {};
mutations[types.RECEIVE_CHECK_ERROR](state, { type, message });
expect(state.checks[type]).toEqual({
isLoading: false,
isValid: false,
message,
});
});
});
describe(types.RECEIVE_CHECK_SUCCESS, () => {
it('sets success for check', () => {
const type = CHECK_CONFIG;
state.checks[type] = {};
mutations[types.RECEIVE_CHECK_SUCCESS](state, type);
expect(state.checks[type]).toEqual({
isLoading: false,
isValid: true,
message: null,
});
});
});
});
......@@ -163,7 +163,7 @@ describe('RelatedIssuesRoot', () => {
next(
request.respondWith(
JSON.stringify({
issues: [issuable1],
issuables: [issuable1],
result: {
message: 'something was successfully related',
status: 'success',
......@@ -196,7 +196,7 @@ describe('RelatedIssuesRoot', () => {
next(
request.respondWith(
JSON.stringify({
issues: [issuable1, issuable2],
issuables: [issuable1, issuable2],
result: {
message: 'something was successfully related',
status: 'success',
......
import Vue from 'vue';
import store from 'ee/operations/store/index';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { GlLoadingIcon } from '@gitlab/ui';
import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue';
import ProjectSearch from 'ee/operations/components/dashboard/project_search.vue';
import TokenizedInput from 'ee/operations/components/tokenized_input/input.vue';
......@@ -10,7 +9,6 @@ import { getChildInstances, mouseEvent, clearState } from '../../helpers';
describe('project search component', () => {
const ProjectSearchComponent = Vue.extend(ProjectSearch);
const GlLoadingIconComponent = Vue.extend(GlLoadingIcon);
const TokenizedInputComponent = Vue.extend(TokenizedInput);
const ProjectAvatarComponent = Vue.extend(ProjectAvatar);
......@@ -84,7 +82,7 @@ describe('project search component', () => {
store.state.searchCount = 1;
vm = mount();
expect(getChildInstances(vm, GlLoadingIconComponent).length).toBe(1);
expect(vm.$el).toContainElement('.loading-container');
});
it('renders search results', () => {
......
......@@ -2,7 +2,7 @@
require 'spec_helper'
describe Gitlab::Webide::Config::Entry::Global do
describe Gitlab::WebIde::Config::Entry::Global do
let(:global) { described_class.new(hash) }
describe '.nodes' do
......@@ -39,7 +39,7 @@ describe Gitlab::Webide::Config::Entry::Global do
it 'creates node object using valid class' do
expect(global.descendants.first)
.to be_an_instance_of Gitlab::Webide::Config::Entry::Terminal
.to be_an_instance_of Gitlab::WebIde::Config::Entry::Terminal
end
it 'sets correct description for nodes' do
......@@ -149,7 +149,7 @@ describe Gitlab::Webide::Config::Entry::Global do
context 'when entry exists' do
it 'returns correct entry' do
expect(global[:terminal])
.to be_an_instance_of Gitlab::Webide::Config::Entry::Terminal
.to be_an_instance_of Gitlab::WebIde::Config::Entry::Terminal
expect(global[:terminal][:before_script].value).to eq ['ls']
end
end
......
......@@ -2,7 +2,7 @@
require 'spec_helper'
describe Gitlab::Webide::Config::Entry::Terminal do
describe Gitlab::WebIde::Config::Entry::Terminal do
let(:entry) { described_class.new(config) }
describe '.nodes' do
......
......@@ -2,7 +2,7 @@
require 'spec_helper'
describe Gitlab::Webide::Config do
describe Gitlab::WebIde::Config do
let(:config) do
described_class.new(yml)
end
......
......@@ -48,10 +48,18 @@ describe Epic do
expect(epics(:start_date_asc)).to eq([epic1, epic2, epic4, epic3])
end
it 'orders by start_date DESC' do
expect(epics(:start_date_desc)).to eq([epic2, epic1, epic4, epic3])
end
it 'orders by end_date ASC' do
expect(epics(:end_date_asc)).to eq([epic3, epic1, epic4, epic2])
end
it 'orders by end_date DESC' do
expect(epics(:end_date_desc)).to eq([epic1, epic3, epic4, epic2])
end
it 'orders by updated_at ASC' do
expect(epics(:updated_asc)).to eq([epic2, epic3, epic1, epic4])
end
......
# frozen_string_literal: true
require 'spec_helper'
describe WebIdeTerminal do
let(:build) { create(:ci_build) }
subject { described_class.new(build) }
it 'returns the show_path of the build' do
expect(subject.show_path).to end_with("/ide_terminals/#{build.id}")
end
it 'returns the retry_path of the build' do
expect(subject.retry_path).to end_with("/ide_terminals/#{build.id}/retry")
end
it 'returns the cancel_path of the build' do
expect(subject.cancel_path).to end_with("/ide_terminals/#{build.id}/cancel")
end
it 'returns the terminal_path of the build' do
expect(subject.terminal_path).to end_with("/jobs/#{build.id}/terminal.ws")
end
end
......@@ -16,4 +16,131 @@ describe Ci::BuildPolicy do
it_behaves_like 'protected environments access'
end
describe 'manage a web ide terminal' do
let(:build_permissions) { %i[read_web_ide_terminal create_build_terminal update_web_ide_terminal] }
set(:maintainer) { create(:user) }
let(:owner) { create(:owner) }
let(:admin) { create(:admin) }
let(:maintainer) { create(:user) }
let(:developer) { create(:user) }
let(:reporter) { create(:user) }
let(:guest) { create(:user) }
let(:project) { create(:project, :public, namespace: owner.namespace) }
let(:pipeline) { create(:ci_empty_pipeline, project: project, source: :webide) }
let(:build) { create(:ci_build, pipeline: pipeline) }
before do
stub_licensed_features(web_ide_terminal: true)
allow(build).to receive(:has_terminal?).and_return(true)
project.add_maintainer(maintainer)
project.add_developer(developer)
project.add_reporter(reporter)
project.add_guest(guest)
end
subject { described_class.new(current_user, build) }
context 'when create_web_ide_terminal access disabled' do
let(:current_user) { admin }
before do
stub_licensed_features(web_ide_terminal: false)
expect(current_user.can?(:create_web_ide_terminal, project)).to eq false
end
it { expect_disallowed(*build_permissions) }
end
context 'when create_web_ide_terminal access enabled' do
context 'with admin' do
let(:current_user) { admin }
it { expect_allowed(*build_permissions) }
context 'when build is not from a webide pipeline' do
let(:pipeline) { create(:ci_empty_pipeline, project: project, source: :chat) }
it { expect_disallowed(:read_web_ide_terminal, :update_web_ide_terminal) }
end
context 'when build has no runner terminal' do
before do
allow(build).to receive(:has_terminal?).and_return(false)
end
it { expect_allowed(:read_web_ide_terminal, :update_web_ide_terminal) }
it { expect_disallowed(:create_build_terminal) }
end
end
shared_examples 'allowed build owner access' do
it { expect_disallowed(*build_permissions) }
context 'when user is the owner of the job' do
let(:build) { create(:ci_build, pipeline: pipeline, user: current_user) }
it { expect_allowed(*build_permissions) }
end
end
shared_examples 'forbidden access' do
it { expect_disallowed(*build_permissions) }
context 'when user is the owner of the job' do
let(:build) { create(:ci_build, pipeline: pipeline, user: current_user) }
it { expect_disallowed(*build_permissions) }
end
end
context 'with owner' do
let(:current_user) { owner }
it_behaves_like 'allowed build owner access'
end
context 'with maintainer' do
let(:current_user) { maintainer }
it_behaves_like 'allowed build owner access'
end
context 'with developer' do
let(:current_user) { developer }
it_behaves_like 'forbidden access'
end
context 'with reporter' do
let(:current_user) { reporter }
it_behaves_like 'forbidden access'
end
context 'with guest' do
let(:current_user) { guest }
it_behaves_like 'forbidden access'
end
context 'with non member' do
let(:current_user) { create(:user) }
it_behaves_like 'forbidden access'
end
end
def expect_allowed(*permissions)
permissions.each { |p| is_expected.to be_allowed(p) }
end
def expect_disallowed(*permissions)
permissions.each do |p|
is_expected.not_to be_allowed(p)
end
end
end
end
......@@ -631,4 +631,70 @@ describe ProjectPolicy do
it { is_expected.to be_allowed(:read_software_license_policy) }
end
end
describe 'create_web_ide_terminal' do
before do
stub_licensed_features(web_ide_terminal: true)
end
subject { described_class.new(current_user, project) }
context 'without ide terminal feature available' do
before do
stub_licensed_features(web_ide_terminal: false)
end
let(:current_user) { admin }
it { is_expected.to be_disallowed(:create_web_ide_terminal) }
end
context 'with admin' do
let(:current_user) { admin }
it { is_expected.to be_allowed(:create_web_ide_terminal) }
end
context 'with owner' do
let(:current_user) { owner }
it { is_expected.to be_allowed(:create_web_ide_terminal) }
end
context 'with maintainer' do
let(:current_user) { maintainer }
it { is_expected.to be_allowed(:create_web_ide_terminal) }
end
context 'with developer' do
let(:current_user) { developer }
it { is_expected.to be_disallowed(:create_web_ide_terminal) }
end
context 'with reporter' do
let(:current_user) { reporter }
it { is_expected.to be_disallowed(:create_web_ide_terminal) }
end
context 'with guest' do
let(:current_user) { guest }
it { is_expected.to be_disallowed(:create_web_ide_terminal) }
end
context 'with non member' do
let(:current_user) { create(:user) }
it { is_expected.to be_disallowed(:create_web_ide_terminal) }
end
context 'with anonymous' do
let(:current_user) { nil }
it { is_expected.to be_disallowed(:create_web_ide_terminal) }
end
end
end
......@@ -14,4 +14,24 @@ describe API::Boards do
it_behaves_like 'milestone board list'
it_behaves_like 'assignee board list'
end
context 'GET /projects/:id/boards/:board_id with special milestones' do
let(:url) { "/projects/#{board_parent.id}/boards/#{board.id}" }
it 'returns board with Upcoming milestone' do
board.update(milestone_id: Milestone::Upcoming.id)
get api(url, user)
expect(json_response["milestone"]["title"]).to eq(Milestone::Upcoming.title)
end
it 'returns board with Started milestone' do
board.update(milestone_id: Milestone::Started.id)
get api(url, user)
expect(json_response["milestone"]["title"]).to eq(Milestone::Started.title)
end
end
end
......@@ -14,12 +14,12 @@ describe API::Runner, :clean_gitlab_redis_shared_state do
describe 'POST /api/v4/jobs/request' do
context 'for web-ide job' do
let(:user) { create(:user) }
let(:service) { Ci::CreateWebideTerminalService.new(project, user, ref: 'master').execute }
let(:service) { Ci::CreateWebIdeTerminalService.new(project, user, ref: 'master').execute }
let(:pipeline) { service[:pipeline] }
let(:build) { pipeline.builds.first }
before do
stub_licensed_features(webide_terminal: true)
stub_licensed_features(web_ide_terminal: true)
stub_webide_config_file(config_content)
project.add_maintainer(user)
......
......@@ -38,26 +38,26 @@ describe Projects::IssueLinksController do
context 'with success' do
let(:user_role) { :developer }
let(:issue_references) { [issue_b.to_reference] }
let(:issuable_references) { [issue_b.to_reference] }
it 'returns success JSON' do
post namespace_project_issue_links_path(issue_links_params(issue_references: issue_references))
post namespace_project_issue_links_path(issue_links_params(issuable_references: issuable_references))
list_service_response = IssueLinks::ListService.new(issue, user).execute
expect(response).to have_gitlab_http_status(200)
expect(json_response).to eq('message' => nil,
'issues' => list_service_response.as_json)
'issuables' => list_service_response.as_json)
end
end
context 'with failure' do
context 'when unauthorized' do
let(:user_role) { :guest }
let(:issue_references) { [issue_b.to_reference] }
let(:issuable_references) { [issue_b.to_reference] }
it 'returns 403' do
post namespace_project_issue_links_path(issue_links_params(issue_references: issue_references))
post namespace_project_issue_links_path(issue_links_params(issuable_references: issuable_references))
expect(response).to have_gitlab_http_status(403)
end
......@@ -65,15 +65,15 @@ describe Projects::IssueLinksController do
context 'when failing service result' do
let(:user_role) { :developer }
let(:issue_references) { ['#999'] }
let(:issuable_references) { ['#999'] }
it 'returns failure JSON' do
post namespace_project_issue_links_path(issue_links_params(issue_references: issue_references))
post namespace_project_issue_links_path(issue_links_params(issuable_references: issuable_references))
list_service_response = IssueLinks::ListService.new(issue, user).execute
expect(response).to have_gitlab_http_status(404)
expect(json_response).to eq('message' => 'No Issue found for given params', 'issues' => list_service_response.as_json)
expect(json_response).to eq('message' => 'No Issue found for given params', 'issuables' => list_service_response.as_json)
end
end
end
......@@ -121,7 +121,7 @@ describe Projects::IssueLinksController do
list_service_response = IssueLinks::ListService.new(issue, user).execute
expect(json_response).to eq('issues' => list_service_response.as_json)
expect(json_response).to eq('issuables' => list_service_response.as_json)
end
end
......
require 'spec_helper'
describe MergeRequestWidgetEntity do
include ProjectForksHelper
set(:user) { create(:user) }
set(:project) { create :project, :repository }
set(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
......@@ -150,6 +152,33 @@ describe MergeRequestWidgetEntity do
expect(subject.as_json[:license_management]).to include(:license_management_full_report_path)
end
end
describe '#managed_licenses_path' do
let(:managed_licenses_path) { api_v4_projects_managed_licenses_path(id: project.id) }
before do
create(:ee_ci_build, :legacy_license_management, pipeline: pipeline)
end
it 'should be a path for target project' do
expect(subject.as_json[:license_management][:managed_licenses_path]).to eq(managed_licenses_path)
end
context 'with fork' do
let(:source_project) { fork_project(project, user, repository: true) }
let(:fork_merge_request) { create(:merge_request, source_project: source_project, target_project: project) }
let(:subject_json) { described_class.new(fork_merge_request, current_user: user, request: request).as_json }
before do
allow(fork_merge_request).to receive_messages(head_pipeline: pipeline)
stub_licensed_features(license_management: true)
end
it 'should be a path for target project' do
expect(subject_json[:license_management][:managed_licenses_path]).to eq(managed_licenses_path)
end
end
end
end
it 'has vulnerability feedbacks path' do
......
# frozen_string_literal: true
require 'spec_helper'
describe WebIdeTerminalEntity do
let(:build) { create(:ci_build) }
let(:entity) { described_class.new(WebIdeTerminal.new(build)) }
subject { entity.as_json }
it { is_expected.to have_key(:id) }
it { is_expected.to have_key(:status) }
it { is_expected.to have_key(:show_path) }
it { is_expected.to have_key(:cancel_path) }
it { is_expected.to have_key(:retry_path) }
it { is_expected.to have_key(:terminal_path) }
end
# frozen_string_literal: true
require 'spec_helper'
describe WebIdeTerminalSerializer do
let(:build) { create(:ci_build) }
subject { described_class.new.represent(WebIdeTerminal.new(build)) }
it 'represents WebIdeTerminalEntity entities' do
expect(described_class.entity_class).to eq(WebIdeTerminalEntity)
end
it 'accepts WebIdeTerminal as a resource' do
expect(subject[:id]).to eq build.id
end
context 'when resource is a build' do
subject { described_class.new.represent(build) }
it 'transforms it into a WebIdeTerminal resource' do
expect(WebIdeTerminal).to receive(:new)
subject
end
end
end
......@@ -14,7 +14,7 @@ describe EpicIssues::CreateService do
let!(:existing_link) { create(:epic_issue, epic: epic, issue: issue3) }
def assign_issue(references)
params = { issue_references: references }
params = { issuable_references: references }
described_class.new(epic, user, params).execute
end
......@@ -93,9 +93,9 @@ describe EpicIssues::CreateService do
end
context 'when the reference list is empty' do
it 'returns an error' do
expect(assign_issue([])).to eq(message: 'No Issue found for given params', status: :error, http_status: 404)
end
subject { assign_issue([]) }
include_examples 'returns an error'
it 'does not create a system note' do
expect { assign_issue([]) }.not_to change { Note.count }
......@@ -124,7 +124,7 @@ describe EpicIssues::CreateService do
allow(extractor).to receive(:analyze)
allow(extractor).to receive(:issues).and_return([issue])
params = { issue_references: [valid_reference] }
params = { issuable_references: [valid_reference] }
control_count = ActiveRecord::QueryRecorder.new { described_class.new(epic, user, params).execute }.count
user = create(:user)
......@@ -135,7 +135,7 @@ describe EpicIssues::CreateService do
group.add_developer(user)
allow(extractor).to receive(:issues).and_return(issues)
params = { issue_references: issues.map { |i| i.to_reference(full: true) } }
params = { issuable_references: issues.map { |i| i.to_reference(full: true) } }
# threshold 24 because 6 queries are generated for each insert
# (savepoint, find, exists, relative_position get, insert, release savepoint)
......@@ -238,7 +238,7 @@ describe EpicIssues::CreateService do
let(:another_epic) { create(:epic, group: group) }
subject do
params = { issue_references: [valid_reference] }
params = { issuable_references: [valid_reference] }
described_class.new(another_epic, user, params).execute
end
......
......@@ -20,7 +20,7 @@ describe IssueLinks::CreateService do
context 'when the reference list is empty' do
let(:params) do
{ issue_references: [] }
{ issuable_references: [] }
end
it 'returns error' do
......@@ -30,7 +30,7 @@ describe IssueLinks::CreateService do
context 'when Issue not found' do
let(:params) do
{ issue_references: ['#999'] }
{ issuable_references: ['#999'] }
end
it 'returns error' do
......@@ -43,14 +43,14 @@ describe IssueLinks::CreateService do
end
context 'when user has no permission to target project Issue' do
let(:target_issue) { create :issue }
let(:target_issuable) { create :issue }
let(:params) do
{ issue_references: [target_issue.to_reference(project)] }
{ issuable_references: [target_issuable.to_reference(project)] }
end
it 'returns error' do
target_issue.project.add_guest(user)
target_issuable.project.add_guest(user)
is_expected.to eq(message: 'No Issue found for given params', status: :error, http_status: 404)
end
......@@ -62,7 +62,7 @@ describe IssueLinks::CreateService do
context 'source and target are the same issue' do
let(:params) do
{ issue_references: [issue.to_reference] }
{ issuable_references: [issue.to_reference] }
end
it 'does not create notes' do
......@@ -85,7 +85,7 @@ describe IssueLinks::CreateService do
let(:another_project_issue_ref) { another_project_issue.to_reference(project) }
let(:params) do
{ issue_references: [issue_a_ref, another_project_issue_ref] }
{ issuable_references: [issue_a_ref, another_project_issue_ref] }
end
before do
......@@ -129,7 +129,7 @@ describe IssueLinks::CreateService do
end
let(:params) do
{ issue_references: [issue_b.to_reference, issue_a.to_reference] }
{ issuable_references: [issue_b.to_reference, issue_a.to_reference] }
end
it 'returns success status' do
......
......@@ -2,6 +2,8 @@
module API
class Files < Grape::API
include APIGuard
FILE_ENDPOINT_REQUIREMENTS = API::NAMESPACE_OR_PROJECT_REQUIREMENTS.merge(file_path: API::NO_SLASH_URL_PART_REGEX)
# Prevents returning plain/text responses for files with .txt extension
......@@ -79,6 +81,8 @@ module API
requires :id, type: String, desc: 'The project ID'
end
resource :projects, requirements: FILE_ENDPOINT_REQUIREMENTS do
allow_access_with_scope :read_repository, if: -> (request) { request.get? || request.head? }
desc 'Get raw file metadata from repository'
params do
requires :file_path, type: String, desc: 'The url encoded path to the file. Ex. lib%2Fclass%2Erb'
......
......@@ -34,6 +34,16 @@ module Gitlab
@diff_files ||= diffs.decorate! { |diff| decorate_diff!(diff) }
end
# This mutates `diff_files` lines.
def unfold_diff_files(positions)
positions_grouped_by_path = positions.group_by { |position| position.file_path }
diff_files.each do |diff_file|
positions = positions_grouped_by_path.fetch(diff_file.file_path, [])
positions.each { |position| diff_file.unfold_diff_lines(position) }
end
end
def diff_file_with_old_path(old_path)
diff_files.find { |diff_file| diff_file.old_path == old_path }
end
......
......@@ -10,6 +10,10 @@ module Gitlab
diff_options: diff_options,
diff_refs: diff_refs)
end
def unfold_diff_lines(positions)
# no-op
end
end
end
end
......
......@@ -57,7 +57,11 @@ module Gitlab
environments: count(::Environment),
clusters: count(::Clusters::Cluster),
clusters_enabled: count(::Clusters::Cluster.enabled),
project_clusters_enabled: count(::Clusters::Cluster.enabled.project_type),
group_clusters_enabled: count(::Clusters::Cluster.enabled.group_type),
clusters_disabled: count(::Clusters::Cluster.disabled),
project_clusters_disabled: count(::Clusters::Cluster.disabled.project_type),
group_clusters_disabled: count(::Clusters::Cluster.disabled.group_type),
clusters_platforms_gke: count(::Clusters::Cluster.gcp_installed.enabled),
clusters_platforms_user: count(::Clusters::Cluster.user_provided.enabled),
clusters_applications_helm: count(::Clusters::Applications::Helm.installed),
......
......@@ -764,6 +764,12 @@ msgstr ""
msgid "An error occurred. Please try again."
msgstr ""
msgid "An unexpected error occurred while checking the project environment."
msgstr ""
msgid "An unexpected error occurred while checking the project runners."
msgstr ""
msgid "Analytics"
msgstr ""
......@@ -2276,12 +2282,18 @@ msgstr ""
msgid "Confidentiality"
msgstr ""
msgid "Configure GitLab runners to start using Web Terminal. %{helpStart}Learn more.%{helpEnd}"
msgstr ""
msgid "Configure Gitaly timeouts."
msgstr ""
msgid "Configure Tracing"
msgstr ""
msgid "Configure a <code>.gitlab-webide.yml</code> file in the <code>.gitlab</code> directory to start using the Web Terminal. %{helpStart}Learn more.%{helpEnd}"
msgstr ""
msgid "Configure automatic git checks and housekeeping on repositories."
msgstr ""
......@@ -4488,6 +4500,9 @@ msgstr ""
msgid "Identity provider single sign on URL"
msgstr ""
msgid "If any job surpasses this timeout threshold, it will be marked as failed. Human readable time input language is accepted like \"1 hour\". Values without specification represent seconds."
msgstr ""
msgid "If disabled, a diverged local branch will not be automatically updated with commits from its remote counterpart, to prevent local data loss. If the default branch (%{default_branch}) has diverged and cannot be updated, mirroring will fail. Other diverged branches are silently ignored."
msgstr ""
......@@ -4936,6 +4951,9 @@ msgstr ""
msgid "Learn more about Kubernetes"
msgstr ""
msgid "Learn more about Web Terminal"
msgstr ""
msgid "Learn more about custom project templates"
msgstr ""
......@@ -6067,9 +6085,6 @@ msgstr ""
msgid "People without permission will never get a notification and won't be able to comment."
msgstr ""
msgid "Per job. If a job passes this threshold, it will be marked as failed"
msgstr ""
msgid "Perform advanced options such as changing path, transferring, or removing the group."
msgstr ""
......@@ -7870,6 +7885,9 @@ msgstr ""
msgid "Sort by"
msgstr ""
msgid "Sort direction"
msgstr ""
msgid "SortOptions|Access level, ascending"
msgstr ""
......@@ -8041,6 +8059,9 @@ msgstr ""
msgid "Starred projects"
msgstr ""
msgid "Start Web Terminal"
msgstr ""
msgid "Start a %{new_merge_request} with these changes"
msgstr ""
......@@ -8598,7 +8619,7 @@ msgstr ""
msgid "This source diff could not be displayed because it is too large."
msgstr ""
msgid "This timeout will take precedence when lower than Project-defined timeout"
msgid "This timeout will take precedence when lower than project-defined timeout and accepts a human readable time input language like \"1 hour\". Values without specification represent seconds."
msgstr ""
msgid "This user has no identities"
......@@ -9613,6 +9634,9 @@ msgstr ""
msgid "You do not have any subscriptions yet"
msgstr ""
msgid "You do not have permission to run the Web Terminal. Please contact a project administrator."
msgstr ""
msgid "You do not have the correct permissions to override the settings from the LDAP group sync."
msgstr ""
......
......@@ -6,6 +6,7 @@
"eslint": "eslint --max-warnings 0 --report-unused-disable-directives --ext .js,.vue .",
"eslint-fix": "eslint --max-warnings 0 --report-unused-disable-directives --ext .js,.vue --fix .",
"eslint-report": "eslint --max-warnings 0 --ext .js,.vue --format html --output-file ./eslint-report.html --no-inline-config .",
"jest": "BABEL_ENV=jest jest --config=config/jest.config.js",
"karma": "BABEL_ENV=${BABEL_ENV:=karma} karma start --single-run true config/karma.config.js",
"karma-coverage": "BABEL_ENV=coverage karma start --single-run true config/karma.config.js",
"karma-start": "BABEL_ENV=karma karma start config/karma.config.js",
......@@ -120,17 +121,23 @@
"@gitlab/eslint-config": "^1.2.0",
"@vue/test-utils": "^1.0.0-beta.25",
"axios-mock-adapter": "^1.15.0",
"babel-core": "^7.0.0-bridge",
"babel-jest": "^23.6.0",
"babel-plugin-dynamic-import-node": "^2.2.0",
"babel-plugin-istanbul": "^5.1.0",
"babel-plugin-rewire": "^1.2.0",
"babel-plugin-transform-es2015-modules-commonjs": "^6.26.2",
"babel-template": "^6.26.0",
"babel-types": "^6.26.0",
"chalk": "^2.4.1",
"commander": "^2.18.0",
"eslint": "~5.6.0",
"eslint-import-resolver-jest": "^2.1.1",
"eslint-import-resolver-webpack": "^0.10.1",
"eslint-plugin-html": "4.0.5",
"eslint-plugin-import": "^2.14.0",
"eslint-plugin-jasmine": "^2.10.1",
"eslint-plugin-jest": "^22.1.0",
"gettext-extractor": "^3.3.2",
"gettext-extractor-vue": "^4.0.1",
"graphql-tag": "^2.10.0",
......@@ -138,6 +145,8 @@
"jasmine-core": "^2.9.0",
"jasmine-diff": "^0.1.3",
"jasmine-jquery": "^2.1.1",
"jest": "^23.6.0",
"jest-junit": "^5.2.0",
"karma": "^3.0.0",
"karma-chrome-launcher": "^2.2.0",
"karma-coverage-istanbul-reporter": "^2.0.4",
......
......@@ -36,6 +36,18 @@ describe Projects::MergeRequests::DiffsController do
end
end
context 'when note has no position' do
before do
create(:legacy_diff_note_on_merge_request, project: project, noteable: merge_request, position: nil)
end
it 'serializes merge request diff collection' do
expect_any_instance_of(DiffsSerializer).to receive(:represent).with(an_instance_of(Gitlab::Diff::FileCollection::MergeRequestDiff), an_instance_of(Hash))
go
end
end
context 'with forked projects with submodules' do
render_views
......
---
env:
jest/globals: true
plugins:
- jest
settings:
import/resolver:
jest:
jestConfigFile: "config/jest.config.js"
it('does nothing', () => {});
......@@ -180,6 +180,23 @@ describe('Api', () => {
});
});
describe('projectRunners', () => {
it('fetches the runners of a project', done => {
const projectPath = 7;
const params = { scope: 'active' };
const mockData = [{ id: 4 }];
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/runners`;
mock.onGet(expectedUrl, { params }).reply(200, mockData);
Api.projectRunners(projectPath, { params })
.then(({ data }) => {
expect(data).toEqual(mockData);
})
.then(done)
.catch(done.fail);
});
});
describe('newLabel', () => {
it('creates a new label', done => {
const namespace = 'some namespace';
......
......@@ -382,24 +382,47 @@ describe('DiffsStoreActions', () => {
const file = { hash: 123, load_collapsed_diff_url: '/load/collapsed/diff/url' };
const data = { hash: 123, parallelDiffLines: [{ lineCode: 1 }] };
const mock = new MockAdapter(axios);
const commit = jasmine.createSpy('commit');
mock.onGet(file.loadCollapsedDiffUrl).reply(200, data);
testAction(
loadCollapsedDiff,
file,
{},
[
{
type: types.ADD_COLLAPSED_DIFFS,
payload: { file, data },
},
],
[],
() => {
loadCollapsedDiff({ commit, getters: { commitId: null } }, file)
.then(() => {
expect(commit).toHaveBeenCalledWith(types.ADD_COLLAPSED_DIFFS, { file, data });
mock.restore();
done();
},
);
})
.catch(done.fail);
});
it('should fetch data without commit ID', () => {
const file = { load_collapsed_diff_url: '/load/collapsed/diff/url' };
const getters = {
commitId: null,
};
spyOn(axios, 'get').and.returnValue(Promise.resolve({ data: {} }));
loadCollapsedDiff({ commit() {}, getters }, file);
expect(axios.get).toHaveBeenCalledWith(file.load_collapsed_diff_url, {
params: { commit_id: null },
});
});
it('should fetch data with commit ID', () => {
const file = { load_collapsed_diff_url: '/load/collapsed/diff/url' };
const getters = {
commitId: '123',
};
spyOn(axios, 'get').and.returnValue(Promise.resolve({ data: {} }));
loadCollapsedDiff({ commit() {}, getters }, file);
expect(axios.get).toHaveBeenCalledWith(file.load_collapsed_diff_url, {
params: { commit_id: '123' },
});
});
});
......
......@@ -199,6 +199,84 @@ describe('DiffsStoreMutations', () => {
expect(state.diffFiles[0].highlighted_diff_lines[0].discussions[0].id).toEqual(1);
});
it('should not duplicate discussions on line', () => {
const diffPosition = {
base_sha: 'ed13df29948c41ba367caa757ab3ec4892509910',
head_sha: 'b921914f9a834ac47e6fd9420f78db0f83559130',
new_line: null,
new_path: '500-lines-4.txt',
old_line: 5,
old_path: '500-lines-4.txt',
start_sha: 'ed13df29948c41ba367caa757ab3ec4892509910',
};
const state = {
latestDiff: true,
diffFiles: [
{
file_hash: 'ABC',
parallel_diff_lines: [
{
left: {
line_code: 'ABC_1',
discussions: [],
},
right: {
line_code: 'ABC_1',
discussions: [],
},
},
],
highlighted_diff_lines: [
{
line_code: 'ABC_1',
discussions: [],
},
],
},
],
};
const discussion = {
id: 1,
line_code: 'ABC_1',
diff_discussion: true,
resolvable: true,
original_position: diffPosition,
position: diffPosition,
diff_file: {
file_hash: state.diffFiles[0].file_hash,
},
};
const diffPositionByLineCode = {
ABC_1: diffPosition,
};
mutations[types.SET_LINE_DISCUSSIONS_FOR_FILE](state, {
discussion,
diffPositionByLineCode,
});
expect(state.diffFiles[0].parallel_diff_lines[0].left.discussions.length).toEqual(1);
expect(state.diffFiles[0].parallel_diff_lines[0].left.discussions[0].id).toEqual(1);
expect(state.diffFiles[0].parallel_diff_lines[0].right.discussions).toEqual([]);
expect(state.diffFiles[0].highlighted_diff_lines[0].discussions.length).toEqual(1);
expect(state.diffFiles[0].highlighted_diff_lines[0].discussions[0].id).toEqual(1);
mutations[types.SET_LINE_DISCUSSIONS_FOR_FILE](state, {
discussion,
diffPositionByLineCode,
});
expect(state.diffFiles[0].parallel_diff_lines[0].left.discussions.length).toEqual(1);
expect(state.diffFiles[0].parallel_diff_lines[0].left.discussions[0].id).toEqual(1);
expect(state.diffFiles[0].parallel_diff_lines[0].right.discussions).toEqual([]);
expect(state.diffFiles[0].highlighted_diff_lines[0].discussions.length).toEqual(1);
expect(state.diffFiles[0].highlighted_diff_lines[0].discussions[0].id).toEqual(1);
});
it('should add legacy discussions to the given line', () => {
const diffPosition = {
base_sha: 'ed13df29948c41ba367caa757ab3ec4892509910',
......
......@@ -12,4 +12,8 @@ describe Gitlab::Diff::FileCollection::Commit do
let(:diffable) { project.commit }
let(:stub_path) { 'bar/branch-test.txt' }
end
it_behaves_like 'unfoldable diff' do
let(:diffable) { project.commit }
end
end
......@@ -2,8 +2,10 @@ require 'spec_helper'
describe Gitlab::Diff::FileCollection::MergeRequestDiff do
let(:merge_request) { create(:merge_request) }
let(:diff_files) { described_class.new(merge_request.merge_request_diff, diff_options: nil).diff_files }
let(:subject) { described_class.new(merge_request.merge_request_diff, diff_options: nil) }
let(:diff_files) { subject.diff_files }
describe '#diff_files' do
it 'does not highlight binary files' do
allow_any_instance_of(Gitlab::Diff::File).to receive(:text?).and_return(false)
......@@ -19,6 +21,11 @@ describe Gitlab::Diff::FileCollection::MergeRequestDiff do
diff_files
end
end
it_behaves_like 'unfoldable diff' do
let(:diffable) { merge_request.merge_request_diff }
end
it 'it uses a different cache key if diff line keys change' do
mr_diff = described_class.new(merge_request.merge_request_diff, diff_options: nil)
......
......@@ -18,6 +18,9 @@ describe Gitlab::UsageData do
gcp_cluster = create(:cluster, :provided_by_gcp)
create(:cluster, :provided_by_user)
create(:cluster, :provided_by_user, :disabled)
create(:cluster, :group)
create(:cluster, :group, :disabled)
create(:cluster, :group, :disabled)
create(:clusters_applications_helm, :installed, cluster: gcp_cluster)
create(:clusters_applications_ingress, :installed, cluster: gcp_cluster)
create(:clusters_applications_cert_managers, :installed, cluster: gcp_cluster)
......@@ -78,7 +81,11 @@ describe Gitlab::UsageData do
environments
clusters
clusters_enabled
project_clusters_enabled
group_clusters_enabled
clusters_disabled
project_clusters_disabled
group_clusters_disabled
clusters_platforms_gke
clusters_platforms_user
clusters_applications_helm
......@@ -128,8 +135,13 @@ describe Gitlab::UsageData do
expect(count_data[:projects_slack_notifications_active]).to eq(2)
expect(count_data[:projects_slack_slash_active]).to eq(1)
expect(count_data[:clusters_enabled]).to eq(6)
expect(count_data[:clusters_disabled]).to eq(1)
expect(count_data[:clusters_enabled]).to eq(7)
expect(count_data[:project_clusters_enabled]).to eq(6)
expect(count_data[:group_clusters_enabled]).to eq(1)
expect(count_data[:clusters_disabled]).to eq(3)
expect(count_data[:project_clusters_disabled]).to eq(1)
expect(count_data[:group_clusters_disabled]).to eq(2)
expect(count_data[:group_clusters_enabled]).to eq(1)
expect(count_data[:clusters_platforms_gke]).to eq(1)
expect(count_data[:clusters_platforms_user]).to eq(1)
expect(count_data[:clusters_applications_helm]).to eq(1)
......
......@@ -54,7 +54,8 @@ shared_examples 'ChronicDurationAttribute writer' do
subject.send("#{virtual_field}=", '-10m')
expect(subject.valid?).to be_falsey
expect(subject.errors&.messages).to include(virtual_field => ['is not a correct duration'])
expect(subject.errors&.messages)
.to include(base: ['Maximum job timeout has a value which could not be accepted'])
end
end
......
......@@ -158,6 +158,29 @@ describe Project do
end
end
end
describe 'ci_pipelines association' do
context 'when feature flag pipeline_ci_sources_only is enabled' do
it 'returns only pipelines from ci_sources' do
stub_feature_flags(pipeline_ci_sources_only: true)
expect(Ci::Pipeline).to receive(:ci_sources).and_call_original
subject.ci_pipelines
end
end
context 'when feature flag pipeline_ci_sources_only is disabled' do
it 'returns all pipelines' do
stub_feature_flags(pipeline_ci_sources_only: false)
expect(Ci::Pipeline).not_to receive(:ci_sources).and_call_original
expect(Ci::Pipeline).to receive(:all).and_call_original.at_least(:once)
subject.ci_pipelines
end
end
end
end
describe 'modules' do
......
......@@ -121,6 +121,13 @@ describe API::Files do
end
end
context 'when PATs are used' do
it_behaves_like 'repository files' do
let(:token) { create(:personal_access_token, scopes: ['read_repository'], user: user) }
let(:current_user) { { personal_access_token: token } }
end
end
context 'when authenticated', 'as a developer' do
it_behaves_like 'repository files' do
let(:current_user) { user }
......@@ -217,6 +224,13 @@ describe API::Files do
end
end
context 'when PATs are used' do
it_behaves_like 'repository files' do
let(:token) { create(:personal_access_token, scopes: ['read_repository'], user: user) }
let(:current_user) { { personal_access_token: token } }
end
end
context 'when unauthenticated', 'and project is private' do
it_behaves_like '404 response' do
let(:request) { get api(route(file_path)), params }
......@@ -317,6 +331,21 @@ describe API::Files do
let(:request) { get api(route(file_path), guest), params }
end
end
context 'when PATs are used' do
it 'returns file by commit sha' do
token = create(:personal_access_token, scopes: ['read_repository'], user: user)
# This file is deleted on HEAD
file_path = "files%2Fjs%2Fcommit%2Ejs%2Ecoffee"
params[:ref] = "6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9"
expect(Gitlab::Workhorse).to receive(:send_git_blob)
get api(route(file_path) + "/raw", personal_access_token: token), params
expect(response).to have_gitlab_http_status(200)
end
end
end
describe "POST /projects/:id/repository/files/:file_path" do
......@@ -362,6 +391,24 @@ describe API::Files do
expect(response).to have_gitlab_http_status(400)
end
context 'with PATs' do
it 'returns 403 with `read_repository` scope' do
token = create(:personal_access_token, scopes: ['read_repository'], user: user)
post api(route(file_path), personal_access_token: token), params
expect(response).to have_gitlab_http_status(403)
end
it 'returns 201 with `api` scope' do
token = create(:personal_access_token, scopes: ['api'], user: user)
post api(route(file_path), personal_access_token: token), params
expect(response).to have_gitlab_http_status(201)
end
end
context "when specifying an author" do
it "creates a new file with the specified author" do
params.merge!(author_email: author_email, author_name: author_name)
......
......@@ -36,4 +36,12 @@ describe IssueSerializer do
expect(json_entity).to match_schema('entities/issue_board')
end
end
context 'board issue serialization' do
let(:serializer) { 'board' }
it 'matches board issue json schema' do
expect(json_entity).to match_schema('entities/issue_board')
end
end
end
......@@ -45,3 +45,19 @@ shared_examples 'diff statistics' do |test_include_stats_flag: true|
end
end
end
shared_examples 'unfoldable diff' do
let(:subject) { described_class.new(diffable, diff_options: nil) }
it 'calls Gitlab::Diff::File#unfold_diff_lines with correct position' do
position = instance_double(Gitlab::Diff::Position, file_path: 'README')
readme_file = instance_double(Gitlab::Diff::File, file_path: 'README')
other_file = instance_double(Gitlab::Diff::File, file_path: 'foo.rb')
nil_path_file = instance_double(Gitlab::Diff::File, file_path: nil)
allow(subject).to receive(:diff_files) { [readme_file, other_file, nil_path_file] }
expect(readme_file).to receive(:unfold_diff_lines).with(position)
subject.unfold_diff_files([position])
end
end
require 'spec_helper'
RSpec.describe 'admin active tab' do
before do
sign_in(create(:admin))
end
describe 'layouts/nav/sidebar/_admin' do
shared_examples 'page has active tab' do |title|
it "activates #{title} tab" do
expect(page).to have_selector('.nav-sidebar .sidebar-top-level-items > li.active', count: 1)
expect(page.find('.nav-sidebar .sidebar-top-level-items > li.active')).to have_content(title)
render
expect(rendered).to have_selector('.nav-sidebar .sidebar-top-level-items > li.active', count: 1)
expect(rendered).to have_css('.nav-sidebar .sidebar-top-level-items > li.active', text: title)
end
end
shared_examples 'page has active sub tab' do |title|
it "activates #{title} sub tab" do
expect(page).to have_selector('.sidebar-sub-level-items > li.active', count: 2)
expect(page.all('.sidebar-sub-level-items > li.active')[1]).to have_content(title)
render
expect(rendered).to have_css('.sidebar-sub-level-items > li.active', text: title)
end
end
context 'on home page' do
before do
visit admin_root_path
allow(controller).to receive(:controller_name).and_return('dashboard')
end
it_behaves_like 'page has active tab', 'Overview'
......@@ -29,7 +28,8 @@ RSpec.describe 'admin active tab' do
context 'on projects' do
before do
visit admin_projects_path
allow(controller).to receive(:controller_name).and_return('projects')
allow(controller).to receive(:controller_path).and_return('admin/projects')
end
it_behaves_like 'page has active tab', 'Overview'
......@@ -38,7 +38,7 @@ RSpec.describe 'admin active tab' do
context 'on groups' do
before do
visit admin_groups_path
allow(controller).to receive(:controller_name).and_return('groups')
end
it_behaves_like 'page has active tab', 'Overview'
......@@ -47,7 +47,7 @@ RSpec.describe 'admin active tab' do
context 'on users' do
before do
visit admin_users_path
allow(controller).to receive(:controller_name).and_return('users')
end
it_behaves_like 'page has active tab', 'Overview'
......@@ -56,7 +56,7 @@ RSpec.describe 'admin active tab' do
context 'on logs' do
before do
visit admin_logs_path
allow(controller).to receive(:controller_name).and_return('logs')
end
it_behaves_like 'page has active tab', 'Monitoring'
......@@ -65,7 +65,7 @@ RSpec.describe 'admin active tab' do
context 'on messages' do
before do
visit admin_broadcast_messages_path
allow(controller).to receive(:controller_name).and_return('broadcast_messages')
end
it_behaves_like 'page has active tab', 'Messages'
......@@ -73,7 +73,7 @@ RSpec.describe 'admin active tab' do
context 'on hooks' do
before do
visit admin_hooks_path
allow(controller).to receive(:controller_name).and_return('hooks')
end
it_behaves_like 'page has active tab', 'Hooks'
......@@ -81,7 +81,7 @@ RSpec.describe 'admin active tab' do
context 'on background jobs' do
before do
visit admin_background_jobs_path
allow(controller).to receive(:controller_name).and_return('background_jobs')
end
it_behaves_like 'page has active tab', 'Monitoring'
......
This source diff could not be displayed because it is too large. You can view the blob instead.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment